// the streaming message timeline + composer (chat mode).
///|
/// The code workspace view. When there are no messages and no streaming turn,
// quick cards). Otherwise render the streaming timeline + composer.
fn code_workspace_view(
model : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
height : Double,
) -> @moui.View[CodeMsg] {
let chat_theme = workbench_conversation_theme(theme, settings.font_size)
let title_theme = workbench_default_typography_theme(theme)
if model.messages.is_empty() && !model.is_streaming {
code_welcome_view(model, settings, title_theme, chat_theme, width, height)
} else {
code_chat_pane_view(model, settings, chat_theme, width, height)
}
}
///|
/// Chat pane: messages fill the area above a bottom-pinned composer.
fn code_chat_pane_view(
model : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
height : Double,
) -> @moui.View[CodeMsg] {
let list_height = code_message_list_height(
model, settings, theme, width, height,
)
let list = code_message_list(model, theme, width, list_height)
// repaint boundary:消息列表内容未变时(点击按钮、开关设置等交互)整块复用
// 为图层,避免全窗口重新光栅化几百段文本;滚动/流式导致内容变化时自动失效。
.repaint_boundary()
let composer = code_composer(model, settings, theme, width)
@views.container(
@views.custom_children_layout(
children=[list, composer],
measure=ctx => {
ctx.constraints.constrain(@geometry.Size::new(width~, height~))
},
place=ctx => {
let composer_h = ctx.children[1].size.height
let list_h = (ctx.frame.size.height - composer_h).max(0.0)
let w = ctx.frame.size.width
[
@geometry.Rect::new(x=0.0, y=0.0, width=w, height=list_h),
@geometry.Rect::new(x=0.0, y=list_h, width=w, height=composer_h),
]
},
paint=(_ctx, _frame) => (),
semantics_label="Code chat pane",
),
width~,
height~,
background=workbench_canvas_background(theme),
padding=0.0,
)
}
///|
fn code_message_list_height(
model : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
height : Double,
) -> Double {
(height - code_composer_height(model, settings, theme, width)).max(0.0)
}
///|
fn code_composer_height(
model : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
) -> Double {
let outer_padding = 16.0
let input_card_height = code_composer_input_card_height(model, theme, width)
let row_spacing = 6.0
let mut content_height = input_card_height
match model.error {
Some(_) => content_height = content_height + 32.0 + row_spacing
None => ()
}
if model.model_picker_open {
content_height = content_height +
row_spacing +
code_model_dropdown_panel_height(settings)
}
outer_padding + content_height
}
///|
fn code_composer_prompt_line_height() -> Double {
22.0
}
///|
fn code_composer_prompt_text_area_height(lines : Int) -> Double {
lines.to_double() * code_composer_prompt_line_height() + 16.0
}
///|
fn code_composer_prompt_lines(
prompt : String,
theme : @moui.Theme,
width : Double,
) -> Int {
if prompt.is_empty() {
2
} else {
let text_width = (width - 16.0).max(64.0)
let lines = chat_message_display_lines(
prompt,
text_width,
theme.typography.body.size,
).length()
if lines < 2 {
2
} else {
lines
}
}
}
///|
fn code_composer_input_card_height(
model : CodeWorkspace,
theme : @moui.Theme,
width : Double,
) -> Double {
let lines = code_composer_prompt_lines(model.prompt, theme, width)
code_composer_prompt_text_area_height(lines) + 34.0
}
///|
/// Brand title + input card, context bar with icons, and bottom quick-action
/// chips. The whole block scrolls so it fits narrow windows.
fn code_welcome_view(
model : CodeWorkspace,
settings : SettingsWorkspace,
title_theme : @moui.Theme,
content_theme : @moui.Theme,
width : Double,
height : Double,
) -> @moui.View[CodeMsg] {
let inner_width = (width - 96.0).max(320.0)
let welcome_block = @views.column(
[
@views.text(
"Code with Mo",
role=Headline,
theme=title_theme,
width=inner_width,
).foreground(workbench_accent(title_theme)),
@views.spacer(min_length=30.0, weight=0.0),
code_input_card(model, settings, content_theme, inner_width),
@views.spacer(min_length=10.0, weight=0.0),
code_context_bar(settings, content_theme, inner_width),
@views.spacer(min_length=50.0, weight=0.0),
@views.container(
quick_action_chips(content_theme, inner_width),
width=inner_width,
padding=0.0,
),
],
spacing=0.0,
align=Stretch,
)
let welcome_area_height = (height - 96.0).max(0.0)
@views.container(
@views.center(
welcome_block,
width~,
height=welcome_area_height,
theme=content_theme,
),
width~,
height~,
background=workbench_canvas_background(title_theme),
padding=48.0,
)
}
///|
/// The input card: a multi-line text area for the prompt and a compact tool
/// row with model and thinking pickers directly before Send.
fn code_input_card(
model : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let text_area_theme = code_input_text_area_theme(theme)
let rows : Array[@moui.View[CodeMsg]] = []
// Prompt text area – the main interactive zone.
rows.push(
@views.text_area(
model.prompt,
on_input=value => PromptChanged(value),
on_submit=Some(SubmitPrompt),
placeholder="帮你编写代码、调试 Bug、优化性能等开发工作…",
lines=4,
variant=Plain,
theme=text_area_theme,
width~,
line_height=25.0,
),
)
rows.push(
@views.row(
[
@views.container(
@views.icon(Link, size=18.0, color=workbench_text_muted(theme)),
width=20.0,
height=20.0,
padding=0.0,
),
@views.spacer(),
code_agent_controls(model, settings, theme, (width - 110.0).max(140.0)),
workbench_send_button(theme, on_tap=SubmitPrompt),
],
spacing=6.0,
align=Center,
),
)
if model.model_picker_open {
rows.push(code_model_dropdown_panel(model, settings, theme, width))
}
@views.container(
@views.column(rows, spacing=1.0, align=Stretch),
width~,
background=workbench_card_background(theme),
corner_radius=14.0,
border=None,
padding=1.0,
)
}
///|
fn code_input_text_area_theme(theme : @moui.Theme) -> @moui.Theme {
let radius_scale = { ..theme.radius_scale, md: 14.0, }
theme.with_radius_scale(radius_scale)
}
///|
/// Compact model chip. Tapping toggles the inline model picker; the expanded
/// panel is rendered by the caller (`code_input_card` / `code_composer`) so it
/// can drop down over the surrounding surface.
fn code_model_picker(
code : CodeWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
code_compact_select_chip(code.current_model, theme, width).on_tap(
ToggleModelPicker,
)
}
///|
fn code_agent_controls(
code : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
match settings.agent_backend {
OpenSeek =>
@views.row(
[
code_model_picker(
code,
theme,
code_model_picker_width(code, max_width=(width - 68.0).max(96.0)),
),
code_thinking_picker(code, theme, 65.0),
],
spacing=6.0,
align=Center,
)
Acp => acp_agent_controls(code, theme, width)
}
}
///|
fn acp_agent_controls(
code : CodeWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let rows : Array[@moui.View[CodeMsg]] = []
match code.session_modes {
Some(modes) =>
if !modes.available_modes.is_empty() {
rows.push(acp_mode_picker(modes, theme, width))
}
None => rows.push(code_compact_select_chip("ACP", theme, width.min(96.0)))
}
if !code.session_config_options.is_empty() {
rows.push(acp_config_controls(code.session_config_options, theme, width))
}
@views.row(rows, spacing=6.0, align=Center)
}
///|
fn acp_mode_picker(
modes : AgentSessionModeState,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let items : Array[@views.ChoiceItem] = []
for mode in modes.available_modes {
items.push(@views.choice_item(mode.id, mode.name))
}
@views.segmented_control(
items,
selected=modes.current_mode_id,
on_select=id => SetSessionMode(id),
size=Compact,
theme~,
width=width.min(260.0),
)
}
///|
fn acp_config_controls(
options : Array[AgentSessionConfigOption],
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let shown_width = width.min(220.0)
let controls : Array[@moui.View[CodeMsg]] = []
for option in options {
if controls.length() >= 1 {
break
}
controls.push(acp_config_control(option, theme, shown_width))
}
@views.row(controls, spacing=6.0, align=Center)
}
///|
fn acp_config_control(
option : AgentSessionConfigOption,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
match option.current_value {
Boolean(value) =>
code_compact_select_chip(
option.name + ": " + (if value { "on" } else { "off" }),
theme,
width,
).on_tap(SetSessionConfigOption(option.id, Boolean(!value)))
ValueId(_) =>
if option.values.is_empty() {
code_compact_select_chip(option.name, theme, width)
} else {
acp_config_value_picker(option, theme, width)
}
}
}
///|
fn acp_config_value_picker(
option : AgentSessionConfigOption,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let items : Array[@views.ChoiceItem] = []
for value in option.values {
items.push(@views.choice_item(value.id, value.name))
}
let selected = match option.current_value {
ValueId(id) => id
Boolean(_) => ""
}
@views.segmented_control(
items,
selected~,
on_select=id => SetSessionConfigOption(option.id, ValueId(id)),
size=Compact,
theme~,
width~,
)
}
///|
fn code_model_picker_width(
code : CodeWorkspace,
max_width? : Double = 320.0,
) -> Double {
let text_width = code.current_model.to_array().length().to_double() * 7.2 +
24.0
text_width.max(74.0).min(max_width)
}
///|
/// The inline-expanding model panel: a provider switcher plus the active
/// provider's model list. Selecting a model or provider dispatches the
/// matching `CodeMsg`; the shell resolves the backend connection.
fn code_model_dropdown_panel(
code : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let panel_width = width.max(240.0)
let rows : Array[@moui.View[CodeMsg]] = []
rows.push(provider_switcher_row(settings, theme, panel_width))
rows.push(
@views.container(
@views.text("模型", role=Caption, theme~, width=panel_width).foreground(
theme.palette.muted,
),
width=panel_width,
height=18.0,
),
)
match settings.active_provider() {
Some(p) =>
if p.models.is_empty() {
rows.push(
@views.text(
"该供应商暂无模型。可在设置中自动检测或手动添加。",
role=Caption,
theme~,
width=panel_width,
).foreground(theme.palette.muted),
)
} else {
for m in p.models {
let selected = code.current_model == m &&
code.current_provider_id == Some(p.id)
rows.push(model_option_row(p.id, m, selected, theme, panel_width))
}
}
None => ()
}
let list_h = code_model_dropdown_list_height(settings)
@views.container(
@views.scroll_view(
@views.column(rows, spacing=4.0, align=Stretch),
width=panel_width,
height=list_h,
),
width=panel_width,
background=workbench_card_background(theme),
corner_radius=12.0,
border=Some(hairline_border(theme)),
padding=8.0,
)
}
///|
fn code_model_dropdown_row_count(settings : SettingsWorkspace) -> Int {
let base_rows = 2
match settings.active_provider() {
Some(p) =>
if p.models.is_empty() {
base_rows + 1
} else {
base_rows + p.models.length()
}
None => base_rows
}
}
///|
fn code_model_dropdown_list_height(settings : SettingsWorkspace) -> Double {
(code_model_dropdown_row_count(settings).to_double() * 44.0 + 16.0).min(300.0)
}
///|
fn code_model_dropdown_panel_height(settings : SettingsWorkspace) -> Double {
code_model_dropdown_list_height(settings) + 16.0
}
///|
/// Horizontal provider chips; the active provider is highlighted. Tapping
/// switches the chat's active provider.
fn provider_switcher_row(
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let count = settings.providers.length().to_double().max(1.0)
let gap = 6.0
let chip_w = ((width - gap * (count - 1.0)) / count).max(64.0)
let chips : Array[@moui.View[CodeMsg]] = []
for p in settings.providers {
let selected = settings.active_provider_id == Some(p.id)
let chip = @views.container(
@views.center(
@views.text(
p.name,
role=Caption,
theme~,
width=(chip_w - 12.0).max(40.0),
height=18.0,
).foreground(
if selected {
workbench_accent(theme)
} else {
workbench_text(theme)
},
),
width=chip_w,
height=22.0,
theme~,
),
width=chip_w,
height=32.0,
background=if selected {
workbench_selector_chip_background(theme)
} else {
workbench_subtle_background(theme)
},
corner_radius=14.0,
border=if selected { Some(hairline_border(theme)) } else { None },
padding=4.0,
).on_tap(CodeMsg::SelectProvider(p.id))
chips.push(chip)
}
@views.row(chips, spacing=6.0, align=Center)
}
///|
/// A single model row. OpenSeek-supported models are selectable; custom ids
/// stay visible but muted until the native backend can parse them.
fn model_option_row(
provider_id : String,
model_id : String,
selected : Bool,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let supported = mo_workbench_model_supported_by_openseek(model_id)
let trailing : @moui.View[CodeMsg] = if selected {
@views.icon(Check, size=16.0, color=workbench_accent(theme))
} else if !supported {
@views.text("待支持", role=Caption, theme~, width=44.0, height=16.0).foreground(
theme.palette.muted,
)
} else {
@views.container(
@views.text("", role=Caption, theme~, width=16.0),
width=16.0,
height=16.0,
)
}
let label_width = if supported { width - 36.0 } else { width - 64.0 }
let row = @views.container(
@views.row(
[
@views.text(
model_id,
role=Label,
theme~,
width=label_width.max(80.0),
height=22.0,
).foreground(
if supported {
workbench_text(theme)
} else {
theme.palette.muted
},
),
trailing,
],
spacing=6.0,
align=Center,
),
width~,
height=40.0,
background=if selected {
workbench_selector_chip_background(theme)
} else if !supported {
workbench_subtle_background(theme)
} else {
@graphics.Color::rgba(r=0.0, g=0.0, b=0.0, a=0.0)
},
corner_radius=8.0,
padding=8.0,
)
if supported {
row.on_tap(ModelChanged(provider_id, model_id))
} else {
row
}
}
///|
fn code_thinking_picker(
code : CodeWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let label = match code.current_thinking {
Normal => "Normal"
High => "High"
Max => "Max"
}
code_compact_select_chip(label, theme, width).on_tap(
ThinkingChanged(next_thinking_choice(code.current_thinking)),
)
}
///|
fn code_compact_select_chip(
label : String,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let content_width = (width - 6.0).max(32.0)
@views.container(
@views.center(
@views.row(
[
@views.text(
label,
role=Caption,
theme~,
width=(content_width - 14.0).max(24.0),
height=18.0,
).foreground(workbench_accent(theme)),
@views.icon(ChevronDown, size=9.0, color=workbench_accent(theme)),
],
spacing=4.0,
align=Center,
),
width=content_width,
height=20.0,
theme~,
),
width~,
height=26.0,
background=workbench_subtle_background(theme),
corner_radius=11.0,
padding=2.0,
)
}
///|
/// Context bar: the active local workspace folder selector.
fn code_context_bar(
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let label = workspace_folder_label(settings.working_dir)
let chip_width = workspace_folder_chip_width(label, max_width=width)
@views.row(
[
@views.container(
@views.row(
[
@views.icon(
FolderOpen,
size=14.0,
color=workbench_text_muted(theme),
),
@views.text(
label,
role=Caption,
theme~,
width=(chip_width - 52.0).max(60.0),
).foreground(workbench_text(theme)),
@views.icon(
ChevronDown,
size=10.0,
color=workbench_text_muted(theme),
),
],
spacing=4.0,
align=Center,
),
width=chip_width,
height=28.0,
background=workbench_subtle_background(theme),
corner_radius=6.0,
padding=6.0,
).on_tap(CodeMsg::PickWorkspaceFolder),
@views.spacer(),
],
spacing=8.0,
align=Start,
).frame(width~)
}
///|
fn workspace_folder_label(path : String) -> String {
let trimmed = path.trim().to_owned()
if trimmed.is_empty() || trimmed == "." {
"选择文件夹"
} else {
workspace_folder_basename(trimmed)
}
}
///|
fn workspace_folder_basename(path : String) -> String {
let chars = path.to_array()
let mut end = chars.length()
while end > 0 && (chars[end - 1] == '/' || chars[end - 1] == '\\') {
end = end - 1
}
if end <= 0 {
path
} else {
let mut last_slash : Int = -1
for index in 0..<end {
if chars[index] == '/' || chars[index] == '\\' {
last_slash = index
}
}
let label = String::from_array(chars[last_slash + 1:end])
if label.is_empty() {
path
} else {
label
}
}
}
///|
fn workspace_folder_chip_width(
label : String,
max_width? : Double = 320.0,
) -> Double {
let text_width = label.to_array().length().to_double() * 8.0 + 52.0
text_width.max(118.0).min(max_width)
}
///|
/// Bottom quick-action chips: four rounded rect capsules with icons.
/// 应用开发 / 项目理解 / 游戏创意 / 工具脚本. Tapping fills the prompt with
/// a preset.
fn quick_action_chips(
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let chip_width = (width - 24.0) / 4.0
@views.row(
[
quick_chip(
"应用开发",
ListTask,
"帮我开发一个应用",
theme,
chip_width,
),
quick_chip(
"项目理解",
Search,
"帮我理解这个项目的结构",
theme,
chip_width,
),
quick_chip(
"游戏创意",
Focus,
"帮我设计一个游戏创意",
theme,
chip_width,
),
quick_chip(
"工具脚本",
Settings,
"帮我写一个工具脚本",
theme,
chip_width,
),
],
spacing=8.0,
align=Center,
)
}
///|
/// A single quick-action chip: icon + label in a rounded rect with border.
fn quick_chip(
label : String,
icon : @views.IconName,
prompt : String,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let text_width = (width - 38.0).max(40.0)
@views.container(
@views.row(
[
@views.icon(icon, size=14.0, color=workbench_accent(theme)),
@views.text(label, role=Caption, theme~, width=text_width, height=20.0).foreground(
workbench_accent(theme),
),
],
spacing=6.0,
align=Center,
),
width~,
height=40.0,
background=workbench_accent_soft(theme),
corner_radius=18.0,
border=Some(hairline_border(theme)),
padding=8.0,
).on_tap(PromptChanged(prompt))
}
///|
/// 消息高度数组的含 spacing 总高(`n` 条消息,`n-1` 个间距)。
fn code_heights_total(heights : Array[Double], spacing : Double) -> Double {
if heights.is_empty() {
return 0.0
}
let mut total = 0.0
for h in heights {
total = total + h
}
total + (heights.length() - 1).to_double() * spacing
}
///|
/// 前 `count` 条消息(含它们之间的 spacing)的高度和,用作窗口顶部的占位高度。
fn code_prefix_height(
heights : Array[Double],
spacing : Double,
count : Int,
) -> Double {
let mut prefix = 0.0
let mut i = 0
while i < count && i < heights.length() {
prefix = prefix + heights[i]
if i + 1 < count {
prefix = prefix + spacing
}
i = i + 1
}
prefix
}
///|
/// 由滚动 offset 推算出需要真实构建的消息区间 `[first, last)`(排他)。
/// 做法:按估算高度累加定位可视区 `[offset, offset + viewport]`,再向上下各延伸
/// `overscan` 条作为缓冲,补偿高度估算与真实布局的微小偏差,并钳制到 `[0, n)`。
/// `follow_latest` 的 `offset=1e9` 会钳到内容底部,自然得到末尾窗口。
fn code_visible_window(
heights : Array[Double],
spacing : Double,
offset : Double,
viewport : Double,
overscan : Int,
) -> (Int, Int) {
let n = heights.length()
if n == 0 {
return (0, 0)
}
let total = code_heights_total(heights, spacing)
let max_scroll = (total - viewport).max(0.0)
let top = offset.min(max_scroll).max(0.0)
let bottom = top + viewport
// 定位 first:含 `top` 的那条。
let mut acc = 0.0
let mut first = n - 1
for i in 0..<n {
let end = acc + heights[i]
if end >= top {
first = i
break
}
acc = end + spacing
}
// 定位 last:起始位置超过 `bottom` 之后的那条(排他上界)。
let mut acc2 = 0.0
let mut last = 0
for i in 0..<n {
if acc2 > bottom {
last = i
break
}
last = i + 1
acc2 = acc2 + heights[i] + spacing
}
first = (first - overscan).max(0)
last = (last + overscan).min(n)
(first, last)
}
///|
/// 窗口化:只构建可视区 ±overscan 内的消息行,窗口外用等高空占位(`spacer`),
/// 消息行带稳定 key(全局下标)。返回需放进 `list(spacing=4)` 的 rows。
fn code_message_window_rows(
model : CodeWorkspace,
theme : @moui.Theme,
width : Double,
viewport : Double,
) -> Array[@moui.View[CodeMsg]] {
let messages = model.messages
let n = messages.length()
let rows : Array[@moui.View[CodeMsg]] = []
if n == 0 {
if code_should_show_streaming_placeholder(model) {
rows.push(code_streaming_placeholder_row(model, theme, width))
}
return rows
}
let spacing = 4.0
let heights = message_row_heights(messages, theme, width)
let (first, last) = code_visible_window(
heights,
spacing,
model.scroll_offset.y,
viewport,
12,
)
if first > 0 {
let top = code_prefix_height(heights, spacing, first)
if top > 0.0 {
rows.push(@views.spacer(min_length=top, weight=0.0))
}
}
for i in first..<last {
let key = "msg-\{i}"
let msg = if i == n - 1 && model.is_streaming {
match messages[i] {
Assistant(content, reasoning) =>
throttled_assistant_message(
content,
reasoning,
stream_message_key(model, i),
theme,
width,
)
other => other
}
} else {
messages[i]
}
rows.push(message_row(msg, theme, width).key(key))
}
if last < n {
// The list's inter-row spacing before this placeholder is supplied by
// `@views.list`; exclude that one gap from the spacer itself.
let bottom = (code_heights_total(heights, spacing) -
code_prefix_height(heights, spacing, last) -
spacing).max(0.0)
if bottom > 0.0 {
rows.push(@views.spacer(min_length=bottom, weight=0.0))
}
}
if code_should_show_streaming_placeholder(model) {
rows.push(code_streaming_placeholder_row(model, theme, width))
}
rows
}
///|
/// 流式消息的节流 key:优先 message_id(跨 rebuild 稳定),缺失时回退全局下标。
fn stream_message_key(model : CodeWorkspace, index : Int) -> String {
match model.last_assistant_message_id {
Some(id) => "msg-\{index}-\{id}"
None => "msg-\{index}"
}
}
///|
/// Streaming message timeline (chat mode). Renders each message via
/// `message_row` inside a scroll view bound to the workspace scroll state.
fn code_message_list(
model : CodeWorkspace,
theme : @moui.Theme,
width : Double,
height : Double,
) -> @moui.View[CodeMsg] {
let inner_width = (width - 56.0).max(320.0)
let inner_height = (height - 20.0).max(0.0)
let rows = code_message_window_rows(model, theme, inner_width, inner_height)
@views.padding_edges(
@views.scroll_view(
@views.list(rows, spacing=4.0),
width=inner_width,
height=inner_height,
offset=model.scroll_offset,
on_scroll=Some(offset => MessageListScrolled(offset)),
request=@moui.ScrollRequest::new(
id=model.scroll_request_id,
offset=model.scroll_offset,
),
),
top=12.0,
left=28.0,
right=28.0,
bottom=8.0,
)
}
///|
fn code_should_show_streaming_placeholder(model : CodeWorkspace) -> Bool {
if !model.is_streaming {
false
} else if model.messages.is_empty() {
true
} else {
match model.messages[model.messages.length() - 1] {
Assistant(content, _) => content.trim().is_empty()
_ => true
}
}
}
///|
fn code_activity_text(model : CodeWorkspace) -> String {
match model.progress_notice {
Some(text) => if text.is_empty() { "Working..." } else { text }
None => "Working..."
}
}
///|
fn code_streaming_placeholder_row(
model : CodeWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let bubble_width = width * 0.82
aligned_message_bubble(
code_streaming_steer_panel(model, theme, bubble_width),
width,
bubble_width,
false,
)
}
///|
fn code_streaming_steer_panel(
model : CodeWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let status_width = (width * 0.34).max(136.0).min(width - 180.0)
let steer_width = (width - status_width - 104.0).max(120.0)
@views.container(
@views.row(
[
@views.container(
@views.icon(Info, size=13.0, color=workbench_accent(theme)),
width=22.0,
height=22.0,
background=workbench_accent_soft(theme),
corner_radius=11.0,
padding=0.0,
),
@views.text(
"AI " + truncate_text(code_activity_text(model), 32),
role=Caption,
theme~,
width=status_width - 30.0,
height=22.0,
).foreground(workbench_text_muted(theme)),
@views.text_field(
model.steer_text,
on_input=value => SteerChanged(value),
on_submit=Some(SubmitSteer),
placeholder="Steer the running turn...",
variant=Filled,
theme~,
width=steer_width,
height=34.0,
),
@views.button(
"Steer",
on_click=SubmitSteer,
variant=Ghost,
theme~,
width=72.0,
height=34.0,
),
],
spacing=8.0,
align=Center,
),
width~,
background=workbench_subtle_background(theme),
corner_radius=12.0,
border=Some(hairline_border(theme)),
padding=8.0,
)
}
///|
/// The chat-mode composer: prompt input + model/thinking pickers + Send, plus
/// a steer field while a turn is streaming.
fn code_composer(
model : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let inner_width = (width - 56.0).max(320.0)
let rows : Array[@moui.View[CodeMsg]] = []
match model.error {
Some(err) =>
rows.push(
@views.text(err, role=Caption, theme~, width=inner_width).foreground(
theme.palette.danger,
),
)
None => ()
}
rows.push(code_composer_input_card(model, settings, theme, inner_width))
if model.model_picker_open {
rows.push(code_model_dropdown_panel(model, settings, theme, inner_width))
}
@views.padding_edges(
@views.column(rows, spacing=6.0, align=Stretch),
top=8.0,
left=28.0,
right=28.0,
bottom=8.0,
)
}
///|
fn code_composer_input_card(
model : CodeWorkspace,
settings : SettingsWorkspace,
theme : @moui.Theme,
width : Double,
) -> @moui.View[CodeMsg] {
let send_diameter = 28.0
let cancel_width = if model.is_streaming { 72.0 } else { 0.0 }
let controls_width = (width - send_diameter - cancel_width - 34.0).max(120.0)
let prompt_lines = code_composer_prompt_lines(model.prompt, theme, width)
let prompt_height = code_composer_prompt_text_area_height(prompt_lines)
let rows : Array[@moui.View[CodeMsg]] = []
rows.push(
@views.text_area(
model.prompt,
on_input=value => CodeMsg::PromptChanged(value),
on_submit=Some(SubmitPrompt),
placeholder=if model.is_streaming {
"Agent 正在运行;可在上方 Steer 调整方向"
} else {
"继续描述你的任务…"
},
lines=prompt_lines,
variant=Plain,
theme~,
width~,
line_height=code_composer_prompt_line_height(),
)
.frame(height=prompt_height)
.disabled(disabled=model.is_streaming),
)
rows.push(
@views.row(
[
code_agent_controls(model, settings, theme, controls_width),
@views.spacer(),
if model.is_streaming {
@views.button(
"Cancel",
on_click=CancelTurn,
variant=Ghost,
theme~,
width=cancel_width,
height=32.0,
)
} else {
@views.container(
@views.text("", role=Caption, theme~, width=0.0),
width=0.0,
height=0.0,
)
},
workbench_send_button(
theme,
diameter=send_diameter,
on_tap=CodeMsg::SubmitPrompt,
).disabled(disabled=model.is_streaming),
],
spacing=6.0,
align=Center,
),
)
@views.container(
@views.column(rows, spacing=0.0, align=Stretch),
width~,
background=workbench_card_background(theme),
corner_radius=14.0,
border=None,
padding=1.0,
)
}
// ---------------------------------------------------------------------------
// Deprecated starter_card_grid / starter_card (replaced by quick_action_chips).
// These can be removed once all callers are migrated.
// ---------------------------------------------------------------------------