///|
/// Whether the work is still streaming (shimmering label) or settled
/// (static tertiary label). Failure copy is written into the label
/// itself, e.g. `"(1/1) Image generation completed, 1 item failed"`.
pub(all) enum ToolCallState {
Doing
Done
} derive(Eq)
///|
pub extend ToolCallState with Eq::{equal, not_equal}
///|
const ToolCallLabelStyle : String = "display:inline-block;min-width:0;max-width:100%;font-size:14px;line-height:22px;font-weight:500;color:var(--sumi-text-tertiary,rgba(255,255,255,0.6))"
///|
const ToolCallTruncateStyle : String = "overflow:hidden;text-overflow:ellipsis;white-space:nowrap"
///|
fn tool_call_label(
label : String,
state : ToolCallState,
truncate : Bool,
) -> @html.Html {
if state is Doing {
shimmer_text(
label,
style=if truncate {
["max-width:100%;min-width:0", ToolCallTruncateStyle]
} else {
[]
},
)
} else {
@html.span(
style=[
ToolCallLabelStyle,
if truncate {
ToolCallTruncateStyle
} else {
"word-break:break-word"
},
],
[@html.text(label)],
)
}
}
///|
/// One line of agent progress: a 16px tool icon, a shimmering or settled
/// label, and — when `expandable` — a chevron that flips between right
/// (collapsed) and down (expanded). Expanding reveals `steps` under a
/// thread line. The whole row is the toggle hit area when `on_toggle`
/// is given; omit `icon` for icon-less rows like "Thinking".
///
/// ```moonbit nocheck
/// tool_call_row(icon=icon_image(), label="(0/1) Image generating…")
/// tool_call_row(icon=icon_image(), state=Done, label="(1/1) Image generation completed")
/// tool_call_row(
/// icon=icon_adjust(), label="2 commands executed",
/// expandable=true, expanded=e, on_toggle=set_e.map(v => _ => !v),
/// steps=["List file directories", "Search for related content"],
/// )
/// ```
pub fn tool_call_row(
label~ : String,
icon? : @html.Html,
state? : ToolCallState = Doing,
expandable? : Bool = false,
expanded? : Bool = false,
on_toggle? : @cmd.Cmd,
steps? : Array[String] = [],
truncate? : Bool = true,
aria_label? : String,
id? : String,
class? : String,
attrs? : @html.Attrs,
style? : Array[String] = [],
) -> @html.Html {
let element_attrs = @internal.sumi_attrs(attrs)
.data_set("slot", "tool-call-row")
.data_set("state", if state is Doing { "doing" } else { "done" })
if expandable {
ignore(
element_attrs.data_set(
"expanded",
if expanded {
"true"
} else {
"false"
},
),
)
}
if aria_label is Some(label_text) {
ignore(element_attrs.aria_label(label_text))
}
let title_children : Array[@html.Html] = []
if icon is Some(tool_icon) {
title_children.push(
@html.span(
style=[
"display:flex;align-self:stretch;align-items:flex-start;padding-top:3px;flex:none",
],
attrs=@html.Attrs::build().data_set("slot", "tool-call-icon"),
[tool_icon],
),
)
}
let text_children : Array[@html.Html] = [
tool_call_label(label, state, truncate),
]
if expandable {
text_children.push(
@html.span(style=["display:inline-flex;flex:none"], [
if expanded {
@primitives.icon_chevron_down_fill(size=14)
} else {
@primitives.icon_chevron_right_fill(size=14)
},
]),
)
}
title_children.push(
@html.span(
style=["display:flex;flex:1;min-width:0;align-items:center;gap:4px"],
text_children,
),
)
let root_children : Array[@html.Html] = [
@html.span(
style=["display:flex;align-items:center;gap:8px;width:100%"],
attrs=@html.Attrs::build().data_set("slot", "tool-call-row-title"),
title_children,
),
]
if expandable && expanded && steps.length() > 0 {
let step_children : Array[@html.Html] = [
@html.span(
style=[
"position:absolute;left:12px;top:-2px;bottom:-7px;width:1px;background:var(--sumi-stroke-primary,rgba(255,255,255,0.08))",
],
attrs=@html.Attrs::build()
.data_set("slot", "tool-call-thread")
.aria_hidden("true"),
([] : Array[@html.Html]),
),
]
for step in steps {
step_children.push(
@html.p(
style=[
"margin:0;font-size:14px;line-height:22px;color:var(--sumi-text-tertiary,rgba(255,255,255,0.6));word-break:break-word",
],
[@html.text(step)],
),
)
}
root_children.push(
@html.span(
style=[
"position:relative;display:block;width:100%;padding-left:28px;padding-right:4px",
],
attrs=@html.Attrs::build().data_set("slot", "tool-call-steps"),
step_children,
),
)
}
let base = [
@internal.SumiBoxSizing,
@internal.SumiFontSans,
@internal.SumiTextRendering,
"display:flex;flex-direction:column;align-items:flex-start;justify-content:center;gap:8px;width:100%;margin:0;padding:0;border:none;background:transparent;text-align:left;color:var(--sumi-text-tertiary,rgba(255,255,255,0.6));appearance:none;-webkit-appearance:none;outline:none",
]
if on_toggle is Some(toggle) {
@html.button(
style=@internal.sumi_styles([..base, "cursor:pointer"], style),
type_="button",
on_click=toggle,
id?,
class?,
attrs=element_attrs,
root_children,
)
} else {
@html.div(
style=@internal.sumi_styles(base, style),
id?,
class?,
attrs=element_attrs,
root_children,
)
}
}