// The window: a sidebar with the engine's settings, the transcript, the // composer, and the two dialogs the engine can block on. // // One shell, three sizes: the sidebar is docked on a desktop (and can be // dragged), slides over the transcript when the window is narrow, and every // control keeps a finger-sized hit area for touch screens. Qt Quick Controls // only, laid out the way Kirigami expects (sidebar + page, cards, header), so // swapping in org.kde.kirigami later stays a QML change. import QtQuick import QtQuick.Controls import QtQuick.Layouts ApplicationWindow { id: win width: initialWidth > 0 ? initialWidth : 1180 height: initialHeight > 0 ? initialHeight : 800 minimumWidth: 360 minimumHeight: 420 visible: true color: Theme.bg title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace") property string statusText: "" property var sessionsView: [] // the store, for the sidebar property bool navOpen: false // the sliding sidebar, compact only property int sidebarWidth: 0 // 0 = follow the window width readonly property bool compact: width < Theme.compactBreakpoint readonly property int sidebarWide: sidebarWidth > 0 ? sidebarWidth : Math.max(228, Math.min(360, Math.round(width * 0.26))) // The settings panel, used docked and in the sliding panel: one definition, // two places. component ShellSidebar: Sidebar { workspace: agent.workspace provider: agent.provider model: agent.model mode: agent.mode approval: agent.approval planMode: agent.planMode running: agent.running status: win.statusText todo: agent.todo sessions: win.sessionsView sessionId: agent.sessionId onModeRequested: function (m) { agent.setMode(m) } onSessionRequested: function (id) { win.switchSession(id) } onSessionNewRequested: win.newSession() onSessionDeleteRequested: function (id) { win.deleteSession(id) } onApprovalRequested: function (a) { agent.setApproval(a) } onPlanModeRequested: function (on) { agent.setPlanMode(on) } } // ---- the transcript -------------------------------------------------- ListModel { id: transcript } function lastIndex() { return transcript.count - 1 } function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null } function addRow(kind, role, state) { transcript.append({ kind: kind, role: role, text: "", complete: false, name: "", state: state, detail: "", output: "", exitCode: 0 }) } // The engine streams one kind at a time: close whatever was open before. function closeOpenRows(exceptKind) { const last = lastRow() if (last && !last.complete && last.kind !== exceptKind) transcript.setProperty(lastIndex(), "complete", true) } function appendDelta(kind, role, text) { closeOpenRows(kind) if (!lastRow() || lastRow().kind !== kind || lastRow().complete) addRow(kind, role, "running") transcript.setProperty(lastIndex(), "text", lastRow().text + text) } // The last tool row with this name that is still open, -1 when none. function findTool(name) { for (let i = transcript.count - 1; i >= 0; i--) { const r = transcript.get(i) if (r.kind !== "tool") continue if (!r.complete && r.name === name) return i if (r.complete) return -1 } return -1 } function openTool(name, state) { closeOpenRows("tool") addRow("tool", "tool", state) transcript.setProperty(lastIndex(), "name", name) return lastIndex() } function submit(text) { transcript.append({ kind: "message", role: "user", text: text, complete: true, name: "", state: "", detail: "", output: "", exitCode: 0 }) agent.send(text) } // ---- sessions -------------------------------------------------------- function reloadSessions() { win.sessionsView = agent.sessions() } // A stored session comes back as rows: the engine holds the transcript // (AgentCore::history()), these rows only mirror it. function replay() { transcript.clear() const rows = agent.history() for (let i = 0; i < rows.length; i++) transcript.append(rows[i]) chat.stickBottom = true } function switchSession(id) { if (agent.switchSession(id)) win.reloadSessions() } function newSession() { if (agent.newSession()) { transcript.clear() win.reloadSessions() } } function deleteSession(id) { if (agent.deleteSession(id)) win.reloadSessions() } // ---- provider, model, key -------------------------------------------- function openSettings() { settings.load(agent.providers(), agent.provider, agent.model) settings.open() } function applySettings(provider, model, key) { const err = agent.useProvider(provider, model, key) settings.note = err.length ? err : "using " + agent.provider + " / " + agent.model settings.choices = agent.modelChoices(provider) settings.syncPick() } Component.onCompleted: win.reloadSessions() // ---- the shell ------------------------------------------------------- header: ToolBar { id: appBar implicitHeight: Theme.headerHeight background: Rectangle { color: Theme.surface Rectangle { anchors.bottom: parent.bottom width: parent.width height: 1 color: Theme.border } } RowLayout { anchors.fill: parent anchors.leftMargin: Theme.pad anchors.rightMargin: Theme.gap spacing: Theme.gap // The docked sidebar has its own title: only the sliding one needs // a way in. ToolButton { objectName: "navButton" visible: win.compact implicitWidth: Theme.touchTarget implicitHeight: Theme.touchTarget ToolTip.visible: hovered && !win.compact ToolTip.text: "Settings and plan" contentItem: Text { text: "☰" color: parent.hovered ? Theme.text : Theme.textDim font.pixelSize: 17 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: Theme.radiusSm color: parent.hovered ? Theme.surfaceHi : "transparent" } onClicked: win.navOpen = !win.navOpen } Text { visible: !win.compact text: "CST-agent" color: Theme.text font.pixelSize: Theme.bodySize font.bold: true } Text { Layout.fillWidth: true elide: Text.ElideMiddle text: agent.workspace.length ? agent.workspace : "(no workspace)" color: Theme.textDim font.pixelSize: Theme.smallSize } Text { objectName: "statusText" visible: win.statusText.length > 0 && !win.compact text: win.statusText color: Theme.textFaint font.pixelSize: Theme.smallSize elide: Text.ElideRight Layout.maximumWidth: 260 } BusyIndicator { running: agent.running visible: agent.running implicitWidth: 22 implicitHeight: 22 } ToolButton { objectName: "settingsButton" implicitWidth: Theme.touchTarget implicitHeight: Theme.touchTarget ToolTip.visible: hovered ToolTip.text: "Provider, model and key" contentItem: Text { text: "⚙" color: parent.hovered ? Theme.text : Theme.textDim font.pixelSize: 16 horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: Theme.radiusSm color: parent.hovered ? Theme.surfaceHi : "transparent" } onClicked: win.openSettings() } ToolButton { objectName: "clearButton" enabled: transcript.count > 0 && !agent.running implicitWidth: win.compact ? Theme.touchTarget : 56 implicitHeight: Theme.touchTarget ToolTip.visible: hovered ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history" contentItem: Text { text: "Clear" color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint font.pixelSize: Theme.smallSize horizontalAlignment: Text.AlignHCenter verticalAlignment: Text.AlignVCenter } background: Rectangle { radius: Theme.radiusSm color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent" } onClicked: { transcript.clear() win.statusText = "" } } } } RowLayout { anchors.fill: parent spacing: 0 ShellSidebar { id: dockedSidebar objectName: "dockedSidebar" visible: !win.compact Layout.fillHeight: true Layout.preferredWidth: win.sidebarWide } // Drag the divider: a mouse affordance that costs nothing on touch // (the handle is only there while the sidebar is docked). Item { id: divider visible: !win.compact Layout.fillHeight: true implicitWidth: 5 Rectangle { anchors.horizontalCenter: parent.horizontalCenter width: 1 height: parent.height color: dividerMa.pressed ? Theme.accentDim : dividerMa.containsMouse ? Theme.surfaceHi : Theme.border } MouseArea { id: dividerMa anchors.fill: parent hoverEnabled: true cursorShape: Qt.SplitHCursor property real startX: 0 property int startW: 0 onPressed: function (mouse) { startX = mouse.x startW = win.sidebarWide } onPositionChanged: function (mouse) { if (pressed) win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX))) } onDoubleClicked: win.sidebarWidth = 0 } } ColumnLayout { Layout.fillWidth: true Layout.fillHeight: true spacing: 0 ChatView { id: chat Layout.fillWidth: true Layout.fillHeight: true model: transcript running: agent.running } Composer { id: composer Layout.fillWidth: true Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL running: agent.running compact: win.compact attachments: agent.pending onSubmitted: function (text) { win.submit(text) } } } } // ---- compact: the sidebar slides in over the transcript -------------- /* Files dropped anywhere on the window: the engine copies them next to * the session and the next prompt carries them. The picker and Ctrl+V in * the composer land in the same list. */ DropArea { id: dropZone objectName: "dropZone" anchors.fill: parent onDropped: function (drop) { if (!drop.hasUrls) return let note = "" let n = 0 for (let i = 0; i < drop.urls.length; i++) { const r = agent.attachFile(composer.localPath(drop.urls[i])) if (r.length) note = r else n++ } composer.note = note.length ? note : n + (n === 1 ? " file attached" : " files attached") drop.accept() } } // While something is dragged over the window, say what will happen. Rectangle { anchors.fill: parent visible: dropZone.containsDrag color: "transparent" border.width: 2 border.color: Theme.accent Text { anchors.centerIn: parent text: "drop to attach" color: Theme.accent font.pixelSize: Theme.bodySize font.bold: true } } Rectangle { id: scrim objectName: "navScrim" visible: win.navOpen && win.compact anchors.fill: parent color: "#000000" opacity: 0.45 MouseArea { anchors.fill: parent onClicked: win.navOpen = false onWheel: function (wheel) { wheel.accepted = true } } } Rectangle { id: navPanel objectName: "navPanel" visible: win.navOpen && win.compact anchors { left: parent.left top: parent.top bottom: parent.bottom } width: Math.min(320, win.width * 0.88) color: Theme.surface border.width: 1 border.color: Theme.border x: win.navOpen && win.compact ? 0 : -width Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } } Flickable { anchors.fill: parent contentHeight: panelSidebar.implicitHeight clip: true ScrollBar.vertical: ScrollBar {} ShellSidebar { id: panelSidebar width: parent.width border.width: 0 } } } ConsentDialog { id: consent onAnswered: function (choice) { agent.answerPermission(choice) } } SettingsDialog { id: settings onApplyRequested: function (provider, model, key) { win.applySettings(provider, model, key) } } AskDialog { id: ask onAnswered: function (text) { agent.answerQuestion(text) } } Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() } Shortcut { sequence: "Ctrl+L" enabled: !agent.running onActivated: { transcript.clear() win.statusText = "" } } // ---- engine -> UI ---------------------------------------------------- Connections { target: agent function onSessionChanged() { win.reloadSessions(); win.replay() } function onSessionListChanged() { win.reloadSessions() } function onAssistantDelta(text) { win.appendDelta("message", "agent", text) } function onThinkDelta(text) { win.appendDelta("think", "agent", text) } function onStatusChanged(text) { win.statusText = text } function onToolEvent(phase, name, detail) { win.closeOpenRows("tool") let i = win.findTool(name) if (phase === "open" || phase === "arg") { if (i < 0) i = win.openTool(name, "pending") if (detail.length) transcript.setProperty(i, "detail", transcript.get(i).detail + detail) return } if (phase === "ready" || phase === "run") { if (i < 0) i = win.openTool(name, phase) transcript.setProperty(i, "state", phase === "run" ? "running" : "queued") transcript.setProperty(i, "detail", detail) return } // done | denied if (i < 0) i = win.openTool(name, phase) transcript.setProperty(i, "state", phase) if (detail.length && transcript.get(i).output.length === 0) transcript.setProperty(i, "output", detail) } function onToolResult(name, exitCode, output) { let i = win.findTool(name) if (i < 0) i = transcript.count - 1 if (i < 0 || transcript.get(i).kind !== "tool") return transcript.setProperty(i, "output", output) transcript.setProperty(i, "exitCode", exitCode) transcript.setProperty(i, "complete", true) } function onPermissionRequested(tool, detail, preview) { consent.tool = tool consent.detail = detail consent.preview = preview consent.open() } function onQuestionAsked(question, options) { ask.question = question ask.optionText = options ask.open() } function onTurnFinished(rc, err) { win.closeOpenRows("") if (err.length) transcript.append({ kind: "note", role: "error", text: err, complete: true, name: "", state: "error", detail: "", output: "", exitCode: 0 }) win.statusText = "" } function onRunningChanged() { if (!agent.running) win.closeOpenRows("") } } }