import QtQuick import QtQuick.Layouts import ZShell.Blobs import ZShell.Config import ZShell.Components import ZShell.Llm import qs.Components import qs.Services Item { id: root required property int index required property var modelData required property var blocks readonly property var segments: modelData.segments required property Item rootParent required property ChatGeneration current readonly property bool isActive: index === blocks.length - 1 property bool expanded: false readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0) readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s)) clip: true implicitHeight: { const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin; const collapsed = layout.implicitHeight; const approval = layout.implicitHeight + approvalPrompt.implicitHeight + approvalPrompt.anchors.topMargin; if (expanded) return expand; else if (pendingApproval) return approval; else return collapsed; } Behavior on implicitHeight { Anim { type: Anim.DefaultEffects } } RowLayout { id: layout anchors.top: parent.top anchors.left: parent.left anchors.right: parent.right Item { implicitHeight: expandBtn.implicitHeight implicitWidth: expandBtn.implicitWidth LoadingIndicator { id: spinnerReasoning implicitSize: collapsedText.implicitHeight anchors.centerIn: parent opacity: root.isActive ? 1 : 0 visible: opacity > 0 Behavior on opacity { Anim {} } } IconButton { id: expandBtn font.pointSize: Tokens.font.size.large icon: "keyboard_arrow_down" inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline anchors.centerIn: parent opacity: root.isActive ? 0 : 1 enabled: opacity > 0 rotation: root.expanded ? 180 : 0 type: IconButton.Text Behavior on rotation { Anim {} } onClicked: root.expanded = !root.expanded } } CustomText { id: collapsedText color: Colors.palette.m3outline font.pointSize: Tokens.font.size.small Layout.fillWidth: true Layout.leftMargin: Tokens.spacing.medium text: { if (root.isActive) { if (root.pendingApproval) return qsTr("Waiting for approval..."); if (root.lastSegment.type === LlmSegment.Type.ToolCall) return qsTr("Using %1...").arg(root.lastSegment.name); return qsTr("Thinking..."); } return qsTr("Worked for %1s").arg((root.totalElapsedMs / 1000).toFixed(1)); } Behavior on opacity { Anim {} } } } Item { id: approvalPrompt anchors.top: layout.bottom anchors.topMargin: Tokens.spacing.small anchors.left: parent.left anchors.right: parent.right implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2 visible: root.pendingApproval CustomRect { id: approvalBg anchors.fill: parent radius: Tokens.rounding.medium color: Colors.palette.m3surface border.width: 1 border.color: Colors.palette.m3outlineVariant ColumnLayout { id: approvalLayout anchors.fill: parent anchors.margins: Tokens.padding.medium spacing: Tokens.spacing.small CustomText { id: requestHeader text: qsTr("Tool call request") font.pointSize: Tokens.font.size.large color: Colors.palette.m3onSurface } Item { id: toolsBg property bool open: false property Item rootParent: root.rootParent property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2 property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2 implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2 Layout.fillWidth: true function reparentWrapper(): void { const newParent = open ? rootParent : toolsBg; const pos = toolsWrapper.mapToItem(newParent, 0, 0); toolsWrapper.parent = newParent; toolsWrapper.x = pos.x; toolsWrapper.y = pos.y; } BlobGroup { id: blobGroup color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer Behavior on color { CAnim {} } } MouseArea { id: backdrop anchors.fill: parent enabled: false hoverEnabled: enabled preventStealing: true parent: toolsBg.open ? toolsBg.rootParent : toolsBg onClicked: toolsBg.open = false } Item { id: toolsWrapper width: toolsBg.width height: toolList.implicitHeight + toolList.anchors.margins * 2 states: State { name: "open" when: toolsBg.open PropertyChanges { backdrop.enabled: true toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased toolsBackground.topRightRadius: Tokens.rounding.largeIncreased toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased // toolsContent.opacity: 1 toolsWrapper.height: toolsBg.openHeight toolsWrapper.width: toolsBg.openWidth toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2 toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2 elevation.opacity: 1 toolBox.opacity: 0 } } transitions: Transition { id: dialogTransition SequentialAnimation { ScriptAction { script: toolsBg.reparentWrapper() } Anim { properties: "x,y" } } PropertyAction { property: "enabled" } Anim { properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius" type: Anim.DefaultEffects } Anim { properties: "width,height" } } Elevation { id: elevation anchors.fill: parent bottomLeftRadius: toolsBackground.bottomLeftRadius bottomRightRadius: toolsBackground.bottomRightRadius level: 4 opacity: 0 radius: toolsBackground.radius transform: Matrix4x4 { matrix: toolsBackground.deformMatrix } } BlobRect { id: toolsBackground anchors.fill: parent bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small deformScale: 0 group: blobGroup opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5) } CustomRect { id: toolBox anchors.fill: parent radius: Tokens.rounding.small StateLayer { onClicked: toolsBg.open = true } ColumnLayout { id: toolList anchors.fill: parent anchors.margins: Tokens.padding.medium Repeater { model: root.current.pendingToolCalls ?? [] delegate: RowLayout { id: toolText required property LlmSegment modelData required property int index spacing: Tokens.spacing.medium function toolTarget(seg: LlmSegment): string { var a = null; try { a = JSON.parse(seg.arguments); } catch (e) {} if (a && typeof a === "object") { const v = a.url ?? a.path; if (v !== undefined && v !== null) return String(v); } return String(seg.arguments); } CustomText { color: Colors.palette.m3onPrimaryContainer text: String(toolText.index + 1) font.pointSize: Tokens.font.size.large } CustomText { color: Colors.palette.m3onPrimaryContainer text: qsTr("%1").arg(toolText.modelData.name) } MaterialIcon { color: Colors.palette.m3onPrimaryContainer text: "arrow_right_alt" } CustomText { color: Colors.palette.m3onPrimaryContainer text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData)) } } } } } } } ButtonRow { Layout.alignment: Qt.AlignRight spacing: Tokens.spacing.medium IconTextButton { icon: "check_circle" text: qsTr("Allow") horizontalPadding: Tokens.padding.large verticalPadding: Tokens.padding.small isRound: true shapeMorph: true onClicked: root.current.approveTools() } IconTextButton { icon: "cancel" text: qsTr("Deny") isRound: true shapeMorph: true inactiveColor: Colors.palette.m3error inactiveOnColor: Colors.palette.m3onError horizontalPadding: Tokens.padding.large verticalPadding: Tokens.padding.small onClicked: root.current.denyTools() } } } } } CustomRect { id: expandedRect anchors.left: parent.left anchors.right: parent.right anchors.top: layout.bottom anchors.bottom: parent.bottom anchors.topMargin: Tokens.spacing.small color: Colors.palette.m3surfaceContainerLow implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 opacity: root.expanded ? 1 : 0 radius: Tokens.rounding.medium visible: opacity > 0 Behavior on opacity { Anim {} } ListView { id: expandedContent anchors.fill: parent anchors.margins: Tokens.padding.medium model: root.segments spacing: Tokens.spacing.medium delegate: Item { id: segment required property LlmSegment modelData required property int index implicitWidth: ListView.view.width implicitHeight: header.implicitHeight + content.implicitHeight + content.anchors.topMargin RowLayout { id: header anchors.left: parent.left anchors.right: parent.right spacing: Tokens.spacing.small MaterialIcon { text: segment.modelData.type === LlmSegment.Type.Reasoning ? "cognition" : "build" fill: 1 } CustomText { Layout.fillWidth: true text: segment.modelData.type === LlmSegment.Type.Reasoning ? qsTr("Thought for %1s").arg((segment.modelData.elapsedMs / 1000).toFixed(1)) : qsTr("Used %1 for %2s").arg(segment.modelData.name).arg((segment.modelData.elapsedMs / 1000).toFixed(1)) } } CustomText { id: content function toolTarget(seg: LlmSegment): string { var a = null; try { a = JSON.parse(seg.arguments); } catch (e) {} if (a && typeof a === "object") { const v = a.url ?? a.path; if (v !== undefined && v !== null) return String(v); } return String(seg.arguments); } function prettyTool(seg: LlmSegment): string { if (seg.name === "webfetch") { switch (seg.status) { case LlmSegment.Status.Running: return qsTr("Fetching"); case LlmSegment.Status.Pending: return qsTr("Pending fetch"); case LlmSegment.Status.Success: return qsTr("Fetched"); case LlmSegment.Status.Error: return qsTr("Failed to fetch"); default: return qsTr("Fetched website"); } } else if (seg.name === "readfile") { switch (seg.status) { case LlmSegment.Status.Running: return qsTr("Reading"); case LlmSegment.Status.Pending: return qsTr("Pending read"); case LlmSegment.Status.Success: return qsTr("Read"); case LlmSegment.Status.Error: return qsTr("Failed to read"); default: return qsTr("Read file"); } } } anchors.left: parent.left anchors.leftMargin: Tokens.padding.small anchors.top: header.bottom anchors.topMargin: Tokens.spacing.small anchors.right: parent.right color: Colors.palette.m3outline font.pointSize: Tokens.font.size.small wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData)) } } } } }