v0.8.8: Qt front end redesign, responsive shell, UI input tests
The window is no longer one file pretending to be a chat box. The transcript, the run panel, the composer and the two consent dialogs are their own QML components over one Theme singleton, and the shell around them adapts to the screen: docked and scrollable on a desktop, a slide-out drawer on a phone. QML (app/qml/, new except Main.qml): - Theme.qml: single QML singleton, dark Breeze-flavoured tokens (surface, border, accent, text/textDim/textFaint, radius, pad, gap, sizes, monospace); diffLineColor() for unified-diff rows, compactBreakpoint 880, touchTarget 34, segmentHeight 30, headerHeight 42. - ChatView.qml: a row-type dispatcher over the transcript model (message, thought, tool, note), follow-the-tail scrolling with a "jump to latest" button once the user scrolls up, and an empty state that says where the agent runs and what it can do. - MessageCard.qml, ThinkBlock.qml, NoteLine.qml, ToolCard.qml (status pill, arguments, collapsible output, 500-line cut, diff colouring, spinner while the call runs), TodoPanel.qml. - Sidebar.qml: workspace/provider/status, mode and approval segmented rows, plan switch, the engine's todo list, version footer; scrolls, so a short window still reaches every control. - Composer.qml: auto-growing field, Enter sends / Shift+Enter newlines, red Stop while a turn runs, autocorrect hints off, finger-sized targets. - ConsentDialog.qml (Deny / Once / Always -> CST_PERM_*) and AskDialog.qml (the model's options become buttons, free text stays possible). - Main.qml: shell with header, draggable sidebar splitter (hover feedback, 200-560 px, double-click resets), transcript, composer, the two dialogs, Esc to cancel and Ctrl+L to clear. Under 880 px the sidebar becomes a drawer with a scrim instead of squeezing the chat away. AgentCore / main.cpp: - provider, model, approval and planMode are properties now; approval and plan mode write back into the engine's config, and renderMarkdown() calls cst_web_md_html(), so a table or a code fence looks like the WebUI's. - the todo hook writes m_todo on the GUI thread (it was written from the worker thread before: a data race on a QString member). - --size WxH for the window (the layout tests drive it), and the Basic style is chosen by default: the Breeze Quick style registers its types with relative URLs, which floods the log and breaks the composer. UI tests (app/src/ui/): - --uitest drives the real window with synthesized input and exits with the number of failures: 15 checks over docking/drawer transitions, mouse clicks and touch taps reaching the engine's mode, the plan switch, the composer arming the send button, and a 460x900 -> 1200x820 resize round trip. - every control the tests touch carries an objectName; the sidebar's implicitHeight binding loop is gone. Tests: cst-app-selftest and --uitest pass in the standalone app build, ctest cst-app-selftest passes in the repo build (38/38 on the host). Version 0.8.8.
This commit is contained in:
@@ -0,0 +1,108 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// on_ask_user: the agent stopped and wants an answer from the human. Options
|
||||
// (when the engine sends any) become buttons; the field is always there.
|
||||
Dialog {
|
||||
id: dialog
|
||||
property string question: ""
|
||||
property string optionText: ""
|
||||
signal answered(string text)
|
||||
|
||||
readonly property var options: {
|
||||
const raw = optionText.split(/[|\n]/).map(s => s.trim()).filter(s => s.length > 0 && s.length < 60)
|
||||
return raw.length > 1 && raw.length <= 8 ? raw : []
|
||||
}
|
||||
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(620, parent ? parent.width - 60 : 620)
|
||||
padding: Theme.pad
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
radius: Theme.radius
|
||||
}
|
||||
|
||||
function send(text) {
|
||||
const t = text.trim()
|
||||
if (!t.length)
|
||||
return
|
||||
answered(t)
|
||||
close()
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
|
||||
Text {
|
||||
text: "The agent asks"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize + 1
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: dialog.question
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.optionText.length > 0 && dialog.options.length === 0
|
||||
text: dialog.optionText
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.options.length > 0
|
||||
spacing: Theme.gap
|
||||
Repeater {
|
||||
model: dialog.options
|
||||
Button {
|
||||
text: modelData
|
||||
onClicked: dialog.send(modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
TextField {
|
||||
id: answer
|
||||
Layout.fillWidth: true
|
||||
placeholderText: "your answer"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
selectByMouse: true
|
||||
onAccepted: dialog.send(text)
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: answer.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
footer: DialogButtonBox {
|
||||
padding: Theme.pad
|
||||
Button {
|
||||
text: "Send"
|
||||
highlighted: true
|
||||
enabled: answer.text.trim().length > 0
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
|
||||
onClicked: dialog.send(answer.text)
|
||||
}
|
||||
}
|
||||
|
||||
onOpened: answer.forceActiveFocus()
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The transcript: cards for the messages, a card per tool call, folded
|
||||
// thinking blocks and one-line notes. Follows the tail of the conversation
|
||||
// until the user scrolls away from it.
|
||||
Item {
|
||||
id: view
|
||||
property var model: null
|
||||
property bool running: false
|
||||
|
||||
ListView {
|
||||
id: list
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: Theme.gapL
|
||||
anchors.rightMargin: Theme.gapL
|
||||
model: view.model
|
||||
clip: true
|
||||
spacing: Theme.gap
|
||||
topMargin: Theme.gapL
|
||||
bottomMargin: Theme.gapL
|
||||
cacheBuffer: 2000
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
|
||||
// The delegate picks the card by row kind; the components below read
|
||||
// the ListModel roles directly, which is why they are declared in the
|
||||
// delegate's scope.
|
||||
delegate: Loader {
|
||||
id: rowLoader
|
||||
width: list.width
|
||||
sourceComponent: model.kind === "tool" ? toolComp
|
||||
: model.kind === "think" ? thinkComp
|
||||
: model.kind === "note" ? noteComp : msgComp
|
||||
|
||||
Component {
|
||||
id: msgComp
|
||||
MessageCard {
|
||||
role: model.role
|
||||
text: model.text
|
||||
complete: model.complete
|
||||
html: (model.role === "agent" || model.role === "error") &&
|
||||
(model.complete || model.text.length > 12000) &&
|
||||
model.text.length > 0
|
||||
? agent.renderMarkdown(model.text) : ""
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: thinkComp
|
||||
ThinkBlock {
|
||||
text: model.text
|
||||
complete: model.complete
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: toolComp
|
||||
ToolCard {
|
||||
name: model.name
|
||||
state: model.state
|
||||
detail: model.detail
|
||||
output: model.output
|
||||
exitCode: model.exitCode
|
||||
hasResult: model.complete
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: noteComp
|
||||
NoteLine {
|
||||
text: model.text
|
||||
level: model.state === "warn" || model.state === "error" ? model.state : "info"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMovementEnded: view.stickBottom = atYEnd
|
||||
onCountChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
|
||||
onContentHeightChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
|
||||
}
|
||||
|
||||
// Follow the tail unless the user asked otherwise.
|
||||
property bool stickBottom: true
|
||||
|
||||
// Empty state: what this window is and what to do with it.
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(parent.width - 80, 520)
|
||||
spacing: Theme.gap
|
||||
visible: view.model ? view.model.count === 0 : true
|
||||
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "CST-agent"
|
||||
color: Theme.text
|
||||
font.pixelSize: 22
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
text: "The engine runs in this window — no server, no second process."
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
Layout.topMargin: Theme.gap
|
||||
text: "Ask for a change, a command to run, a file to explain.\n" +
|
||||
"Every write, delete and command asks first: Deny / Once / Always.\n" +
|
||||
"Enter sends, Shift+Enter adds a line, Esc stops a running turn."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
|
||||
// Jump back to the live end of the conversation.
|
||||
RoundButton {
|
||||
anchors.right: parent.right
|
||||
anchors.bottom: parent.bottom
|
||||
anchors.margins: Theme.padL
|
||||
width: Theme.jumpButton
|
||||
height: Theme.jumpButton
|
||||
visible: !view.stickBottom && list.contentHeight > list.height
|
||||
text: "↓"
|
||||
onClicked: {
|
||||
view.stickBottom = true
|
||||
list.positionViewAtEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The input line: multiline, Enter sends, the button changes with the turn
|
||||
// state. Sized for a thumb as much as for a mouse, and it asks the platform
|
||||
// keyboard not to autocorrect what is usually code or a path.
|
||||
Rectangle {
|
||||
id: root
|
||||
objectName: "composer"
|
||||
property bool running: false
|
||||
property bool compact: false
|
||||
property alias text: input.text
|
||||
signal submitted(string text)
|
||||
|
||||
function submit() {
|
||||
const t = input.text.trim()
|
||||
if (!t.length || running)
|
||||
return
|
||||
input.text = ""
|
||||
submitted(t)
|
||||
}
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? Theme.accentDim : Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
|
||||
TextArea {
|
||||
id: input
|
||||
objectName: "composerInput"
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
|
||||
Math.max(56, contentHeight + 12))
|
||||
placeholderText: root.running
|
||||
? "the turn is running — Esc stops it"
|
||||
: (root.compact
|
||||
? "Ask for something…"
|
||||
: "Ask for something (Enter sends, Shift+Enter for a new line)")
|
||||
placeholderTextColor: Theme.textFaint
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: TextArea.Wrap
|
||||
selectByMouse: true
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
|
||||
Qt.ImhNoAutoUppercase
|
||||
background: Rectangle { color: "transparent" }
|
||||
enabled: !root.running
|
||||
|
||||
Keys.onPressed: function (ev) {
|
||||
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
|
||||
if (!(ev.modifiers & Qt.ShiftModifier)) {
|
||||
root.submit()
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: action
|
||||
objectName: "sendButton"
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? 68 : 88
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
text: root.running ? "Stop" : "Send"
|
||||
enabled: root.running || input.text.trim().length > 0
|
||||
onClicked: root.running ? agent.cancel() : root.submit()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: !action.enabled ? Theme.surfaceAlt
|
||||
: root.running ? (action.hovered ? "#e05666" : Theme.err)
|
||||
: (action.hovered ? "#5cc0f0" : Theme.accent)
|
||||
}
|
||||
contentItem: Text {
|
||||
text: action.text
|
||||
color: action.enabled ? "#12161a" : Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// On a narrow window the hint would wrap into the editor's space.
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: !root.compact
|
||||
text: root.running
|
||||
? "working — Esc cancels the turn"
|
||||
: "Enter sends · Shift+Enter newline · Esc stops · every change asks first"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The engine's on_permission hook is blocked on this dialog: nothing runs
|
||||
// until one of the three answers goes back.
|
||||
Dialog {
|
||||
id: dialog
|
||||
property string tool: ""
|
||||
property string detail: ""
|
||||
property string preview: ""
|
||||
signal answered(int choice)
|
||||
|
||||
modal: true
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(760, parent ? parent.width - 60 : 760)
|
||||
padding: Theme.pad
|
||||
title: ""
|
||||
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
radius: Theme.radius
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle {
|
||||
width: 8
|
||||
height: 8
|
||||
radius: 4
|
||||
color: Theme.warn
|
||||
}
|
||||
Text {
|
||||
text: "Permission requested"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize + 1
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: dialog.tool
|
||||
color: Theme.warn
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: dialog.detail.length > 0
|
||||
text: dialog.detail
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(320, previewText.implicitHeight + Theme.pad * 2)
|
||||
visible: dialog.preview.length > 0
|
||||
color: Theme.bg
|
||||
radius: Theme.radiusSm
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
|
||||
Flickable {
|
||||
id: flick
|
||||
anchors.fill: parent
|
||||
anchors.margins: 8
|
||||
contentWidth: previewText.width
|
||||
contentHeight: previewText.height
|
||||
clip: true
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
Text {
|
||||
id: previewText
|
||||
width: Math.max(flick.width, implicitWidth)
|
||||
text: dialog.preview
|
||||
color: Theme.text
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "“Always” is remembered for this tool in .agent/permissions.json — the TUI and the CLI see the same decision."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
footer: DialogButtonBox {
|
||||
spacing: Theme.gap
|
||||
padding: Theme.pad
|
||||
Button {
|
||||
text: "Deny"
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
|
||||
onClicked: { dialog.answered(0); dialog.close() }
|
||||
}
|
||||
Button {
|
||||
text: "Once"
|
||||
highlighted: true
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
|
||||
onClicked: { dialog.answered(1); dialog.close() }
|
||||
}
|
||||
Button {
|
||||
text: "Always"
|
||||
DialogButtonBox.buttonRole: DialogButtonBox.ActionRole
|
||||
onClicked: { dialog.answered(2); dialog.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
+347
-171
@@ -1,9 +1,11 @@
|
||||
// The app window: chat on the left, tool runs and consent on top of it.
|
||||
// The window: a sidebar with the engine's settings, the transcript, the
|
||||
// composer, and the two dialogs the engine can block on.
|
||||
//
|
||||
// Qt Quick Controls only, laid out the way Kirigami expects (single column,
|
||||
// cards, drawer for navigation) so that swapping `import QtQuick.Controls`
|
||||
// for `import org.kde.kirigami as Kirigami` and Page/Card wrappers is a QML
|
||||
// change, not a rewrite.
|
||||
// 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
|
||||
@@ -11,219 +13,393 @@ import QtQuick.Layouts
|
||||
|
||||
ApplicationWindow {
|
||||
id: win
|
||||
width: 980
|
||||
height: 720
|
||||
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 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
|
||||
onModeRequested: function (m) { agent.setMode(m) }
|
||||
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)
|
||||
}
|
||||
|
||||
// ---- 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
|
||||
Label {
|
||||
text: agent.workspace.length ? agent.workspace : "(no workspace)"
|
||||
elide: Text.ElideMiddle
|
||||
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: 24
|
||||
implicitHeight: 24
|
||||
implicitWidth: 22
|
||||
implicitHeight: 22
|
||||
}
|
||||
ComboBox {
|
||||
id: modeBox
|
||||
model: ["coding", "general"]
|
||||
currentIndex: agent.mode === "general" ? 1 : 0
|
||||
onActivated: agent.setMode(model[currentIndex])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- consent: the engine's on_permission hook waits for this ----
|
||||
Dialog {
|
||||
id: consent
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(720, win.width - 40)
|
||||
modal: true
|
||||
property string tool: ""
|
||||
property string detail: ""
|
||||
property string preview: ""
|
||||
title: "Allow " + tool + "?"
|
||||
contentItem: ColumnLayout {
|
||||
Label { text: consent.detail; wrapMode: Text.Wrap; Layout.fillWidth: true }
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: Math.min(300, previewText.implicitHeight + 16)
|
||||
visible: consent.preview.length > 0
|
||||
TextArea {
|
||||
id: previewText
|
||||
text: consent.preview
|
||||
readOnly: true
|
||||
font.family: "monospace"
|
||||
wrapMode: TextArea.NoWrap
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
footer: DialogButtonBox {
|
||||
Button { text: "Deny"; DialogButtonBox.buttonRole: DialogButtonBox.RejectRole }
|
||||
Button { text: "Once"; DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole }
|
||||
Button { text: "Always"; DialogButtonBox.buttonRole: DialogButtonBox.ActionRole }
|
||||
onRejected: { agent.answerPermission(0); consent.close() }
|
||||
onAccepted: { agent.answerPermission(1); consent.close() }
|
||||
onClicked: function (button) {
|
||||
if (button.text === "Always") { agent.answerPermission(2); consent.close() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- ask_user ----
|
||||
Dialog {
|
||||
id: question
|
||||
anchors.centerIn: parent
|
||||
width: Math.min(600, win.width - 40)
|
||||
modal: true
|
||||
property string text: ""
|
||||
property string options: ""
|
||||
title: "The agent asks"
|
||||
contentItem: ColumnLayout {
|
||||
Label { text: question.text; wrapMode: Text.Wrap; Layout.fillWidth: true }
|
||||
Label {
|
||||
visible: question.options.length > 0
|
||||
text: question.options
|
||||
wrapMode: Text.Wrap
|
||||
Layout.fillWidth: true
|
||||
opacity: 0.7
|
||||
}
|
||||
TextField { id: answerField; Layout.fillWidth: true; placeholderText: "your answer" }
|
||||
}
|
||||
footer: DialogButtonBox {
|
||||
Button { text: "Send"; DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole }
|
||||
onAccepted: { agent.answerQuestion(answerField.text); question.close() }
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
RowLayout {
|
||||
anchors.fill: parent
|
||||
anchors.margins: 12
|
||||
spacing: 8
|
||||
spacing: 0
|
||||
|
||||
ListView {
|
||||
id: chat
|
||||
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
|
||||
model: transcript
|
||||
clip: true
|
||||
spacing: 8
|
||||
delegate: Frame {
|
||||
width: chat.width
|
||||
background: Rectangle {
|
||||
color: model.role === "user" ? "#2b579a22" : "#00000000"
|
||||
border.color: "#88888833"
|
||||
radius: 6
|
||||
}
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
spacing: 2
|
||||
Label {
|
||||
text: model.role === "user" ? "You" : "Agent"
|
||||
font.bold: true
|
||||
opacity: 0.7
|
||||
}
|
||||
TextEdit {
|
||||
text: model.text
|
||||
readOnly: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
selectByMouse: true
|
||||
Layout.fillWidth: true
|
||||
}
|
||||
}
|
||||
}
|
||||
onCountChanged: positionViewAtEnd()
|
||||
}
|
||||
spacing: 0
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
TextArea {
|
||||
id: composer
|
||||
ChatView {
|
||||
id: chat
|
||||
Layout.fillWidth: true
|
||||
Layout.preferredHeight: 90
|
||||
placeholderText: agent.running ? "… working (Esc cancels)"
|
||||
: "Ask something (Enter sends, Shift+Enter for a new line)"
|
||||
wrapMode: TextArea.Wrap
|
||||
enabled: !agent.running
|
||||
Keys.onPressed: function (ev) {
|
||||
if (ev.key === Qt.Key_Return && !(ev.modifiers & Qt.ShiftModifier)) {
|
||||
sendButton.clicked()
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
Layout.fillHeight: true
|
||||
model: transcript
|
||||
running: agent.running
|
||||
}
|
||||
ColumnLayout {
|
||||
Button {
|
||||
id: sendButton
|
||||
text: "Send"
|
||||
enabled: !agent.running && composer.text.trim().length > 0
|
||||
onClicked: {
|
||||
transcript.append({ role: "user", text: composer.text })
|
||||
transcript.append({ role: "agent", text: "" })
|
||||
agent.send(composer.text)
|
||||
composer.text = ""
|
||||
}
|
||||
}
|
||||
Button {
|
||||
text: "Stop"
|
||||
enabled: agent.running
|
||||
onClicked: agent.cancel()
|
||||
}
|
||||
|
||||
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
|
||||
onSubmitted: function (text) { win.submit(text) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Shortcut { sequence: "Escape"; onActivated: agent.cancel() }
|
||||
|
||||
// ---- engine -> UI ----
|
||||
function appendToAgent(text) {
|
||||
if (transcript.count === 0)
|
||||
transcript.append({ role: "agent", text: "" })
|
||||
const last = transcript.count - 1
|
||||
transcript.setProperty(last, "text", transcript.get(last).text + text)
|
||||
// ---- compact: the sidebar slides in over the transcript --------------
|
||||
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) }
|
||||
}
|
||||
|
||||
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 onAssistantDelta(text) { appendToAgent(text) }
|
||||
function onStatusChanged(text) {
|
||||
transcript.append({ role: "agent", text: "· " + text })
|
||||
}
|
||||
|
||||
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) {
|
||||
transcript.append({
|
||||
role: "agent",
|
||||
text: "[" + name + " " + phase + (detail.length ? ": " + 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) {
|
||||
transcript.append({
|
||||
role: "agent",
|
||||
text: "[" + name + " exit=" + exitCode + "]\n" + 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(text, options) {
|
||||
question.text = text
|
||||
question.options = options
|
||||
answerField.text = ""
|
||||
question.open()
|
||||
|
||||
function onQuestionAsked(question, options) {
|
||||
ask.question = question
|
||||
ask.optionText = options
|
||||
ask.open()
|
||||
}
|
||||
|
||||
function onTurnFinished(rc, err) {
|
||||
win.closeOpenRows("")
|
||||
if (err.length)
|
||||
transcript.append({ role: "agent", text: "error: " + err })
|
||||
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("")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// One message in the transcript: the prompt, the answer, or an error. The
|
||||
// answer arrives as plain text while it streams and is rendered from markdown
|
||||
// (by the engine, through AgentCore::renderMarkdown) once it is complete.
|
||||
Rectangle {
|
||||
id: card
|
||||
property string role: "agent" // user | agent | error
|
||||
property string text: ""
|
||||
property string html: ""
|
||||
property bool complete: true
|
||||
|
||||
radius: Theme.radius
|
||||
color: role === "user" ? Theme.surfaceAlt : Theme.surface
|
||||
border.width: 1
|
||||
border.color: role === "error" ? Theme.err
|
||||
: role === "user" ? Theme.border : Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: Theme.gap
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle {
|
||||
width: 6
|
||||
height: 6
|
||||
radius: 3
|
||||
color: card.role === "user" ? Theme.accent
|
||||
: card.role === "error" ? Theme.err : Theme.ok
|
||||
}
|
||||
Text {
|
||||
text: card.role === "user" ? "You"
|
||||
: card.role === "error" ? "Error" : "CST-agent"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
visible: !card.complete
|
||||
text: "streaming…"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
ToolButton {
|
||||
implicitWidth: 26
|
||||
implicitHeight: 22
|
||||
visible: card.text.length > 0
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Copy"
|
||||
contentItem: Text {
|
||||
text: "⧉"
|
||||
color: parent.hovered ? Theme.text : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: {
|
||||
plain.text = card.text
|
||||
plain.selectAll()
|
||||
plain.copy()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hidden holder so "copy" always has the plain text, rtf or not.
|
||||
TextEdit {
|
||||
id: plain
|
||||
visible: false
|
||||
}
|
||||
|
||||
TextEdit {
|
||||
id: streamText
|
||||
visible: card.html.length === 0
|
||||
Layout.fillWidth: true
|
||||
text: card.text
|
||||
readOnly: true
|
||||
selectByMouse: true
|
||||
wrapMode: TextEdit.Wrap
|
||||
color: card.role === "error" ? Theme.err : Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
selectionColor: Theme.accentDim
|
||||
}
|
||||
|
||||
Text {
|
||||
visible: card.html.length > 0
|
||||
Layout.fillWidth: true
|
||||
text: card.html
|
||||
textFormat: Text.RichText
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
wrapMode: Text.Wrap
|
||||
onLinkActivated: function (link) { Qt.openUrlExternally(link) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import QtQuick
|
||||
import QtQuick.Layouts
|
||||
|
||||
// A one-line note from the engine (status text, a refusal, a hint).
|
||||
RowLayout {
|
||||
id: note
|
||||
property string text: ""
|
||||
property string level: "info" // info | warn | error
|
||||
spacing: Theme.gap
|
||||
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
Layout.topMargin: 6
|
||||
width: 5
|
||||
height: 5
|
||||
radius: 3
|
||||
color: note.level === "error" ? Theme.err
|
||||
: note.level === "warn" ? Theme.warn : Theme.textFaint
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: note.text
|
||||
color: note.level === "error" ? Theme.err
|
||||
: note.level === "warn" ? Theme.warn : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// What the engine is set to: where it runs, how it frames itself, when it
|
||||
// asks, whether it is planning. Everything here writes straight into the
|
||||
// engine's config (AgentCore), never into a copy the engine cannot see.
|
||||
//
|
||||
// The panel scrolls (a phone-sized window cannot show it all) and every
|
||||
// control keeps a finger-sized hit area.
|
||||
Rectangle {
|
||||
id: side
|
||||
property string workspace: ""
|
||||
property string provider: ""
|
||||
property string model: ""
|
||||
property string mode: "coding"
|
||||
property int approval: 1 // cst_approval_t
|
||||
property bool planMode: false
|
||||
property bool running: false
|
||||
property string status: ""
|
||||
property string todo: ""
|
||||
|
||||
signal modeRequested(string mode)
|
||||
signal approvalRequested(int approval)
|
||||
signal planModeRequested(bool on)
|
||||
|
||||
readonly property alias contentColumn: col
|
||||
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
implicitHeight: col.implicitHeight + Theme.pad * 2
|
||||
|
||||
// One selectable row of a segmented control.
|
||||
component Seg: Button {
|
||||
id: seg
|
||||
property bool current: false
|
||||
flat: true
|
||||
implicitHeight: Theme.segmentHeight
|
||||
padding: 0
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: seg.current ? Theme.accentDim : (seg.hovered ? Theme.surfaceAlt : "transparent")
|
||||
border.width: 1
|
||||
border.color: seg.current ? Theme.accent : Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: seg.text
|
||||
color: seg.current ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
|
||||
Flickable {
|
||||
anchors.fill: parent
|
||||
contentWidth: width
|
||||
contentHeight: col.implicitHeight + Theme.pad * 2
|
||||
clip: true
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ColumnLayout {
|
||||
id: col
|
||||
x: Theme.pad
|
||||
y: Theme.pad
|
||||
width: parent.width - Theme.pad * 2
|
||||
spacing: Theme.gapL
|
||||
|
||||
// --- where ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
|
||||
Text {
|
||||
text: "CST-agent"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
visible: side.running
|
||||
text: "turn running"
|
||||
color: Theme.warn
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.workspace.length ? side.workspace : "(no workspace)"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideMiddle
|
||||
ToolTip.visible: wsHover.hovered
|
||||
ToolTip.text: side.workspace
|
||||
HoverHandler { id: wsHover }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.provider.length > 0 || side.model.length > 0
|
||||
text: (side.provider.length ? side.provider : "?") +
|
||||
(side.model.length ? " · " + side.model : "")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.status.length > 0
|
||||
text: side.status
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// --- how it frames itself ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Mode"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Seg {
|
||||
objectName: "modeCoding"
|
||||
Layout.fillWidth: true
|
||||
text: "coding"
|
||||
current: side.mode !== "general"
|
||||
onClicked: side.modeRequested("coding")
|
||||
}
|
||||
Seg {
|
||||
objectName: "modeGeneral"
|
||||
Layout.fillWidth: true
|
||||
text: "general"
|
||||
current: side.mode === "general"
|
||||
onClicked: side.modeRequested("general")
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.mode === "general" ? "documents, data, everyday files"
|
||||
: "code framed: repos, builds, tests"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- when it asks ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Text { text: "Approval"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "ask"
|
||||
current: side.approval === 0
|
||||
onClicked: side.approvalRequested(0)
|
||||
}
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "auto"
|
||||
current: side.approval === 1
|
||||
onClicked: side.approvalRequested(1)
|
||||
}
|
||||
Seg {
|
||||
Layout.fillWidth: true
|
||||
text: "yolo"
|
||||
current: side.approval === 2
|
||||
onClicked: side.approvalRequested(2)
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: side.approval === 0 ? "every tool asks first"
|
||||
: side.approval === 2 ? "never asks — writes go through"
|
||||
: "sandbox and rules decide, asks on doubt"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 2
|
||||
spacing: Theme.gap
|
||||
Switch {
|
||||
id: planSwitch
|
||||
objectName: "planSwitch"
|
||||
checked: side.planMode
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: side.planModeRequested(checked)
|
||||
}
|
||||
Text {
|
||||
text: "Plan mode"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.planMode
|
||||
text: "read-only tools and a planning note"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- the plan the engine keeps ---
|
||||
TodoPanel {
|
||||
Layout.fillWidth: true
|
||||
text: side.todo
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: Theme.gap
|
||||
text: "engine in-process · v" + Qt.application.version
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
// One place for the look: colors, spacing, fonts. The window, the cards and
|
||||
// the dialogs all read from here, so re-theming (or swapping in Kirigami's
|
||||
// Kirigami.Theme) is a single file.
|
||||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
// Breeze-dark-ish (the desktop the app is built for), no hard contrast.
|
||||
readonly property color bg: "#15181a"
|
||||
readonly property color surface: "#1e2225"
|
||||
readonly property color surfaceAlt: "#262b2f"
|
||||
readonly property color surfaceHi: "#313840"
|
||||
readonly property color border: "#343b41"
|
||||
readonly property color text: "#eff0f1"
|
||||
readonly property color textDim: "#9aa2a8"
|
||||
readonly property color textFaint: "#6d757b"
|
||||
readonly property color accent: "#3daee9"
|
||||
readonly property color accentDim: "#1d6f95"
|
||||
readonly property color ok: "#27ae60"
|
||||
readonly property color warn: "#f67400"
|
||||
readonly property color err: "#da4453"
|
||||
|
||||
readonly property int radius: 6
|
||||
readonly property int radiusSm: 3
|
||||
readonly property int gap: 8
|
||||
readonly property int gapL: 14
|
||||
readonly property int pad: 12
|
||||
readonly property int padL: 16
|
||||
|
||||
readonly property int bodySize: 14
|
||||
readonly property int smallSize: 12
|
||||
readonly property int monoSize: 13
|
||||
readonly property string mono: "monospace"
|
||||
|
||||
readonly property int sidebarWidth: 268
|
||||
readonly property int contentMax: 1100
|
||||
|
||||
// Layout: below the breakpoint the sidebar slides over the transcript
|
||||
// instead of taking a column, and controls stay thumb-sized above it
|
||||
// (a finger needs ~34-40 px, a mouse is happy with 26).
|
||||
readonly property int compactBreakpoint: 880
|
||||
readonly property int touchTarget: 34
|
||||
readonly property int segmentHeight: 30
|
||||
readonly property int headerHeight: 42
|
||||
readonly property int jumpButton: 40
|
||||
|
||||
// The tool output / diff line colors, used by ToolCard.
|
||||
function lineColor(line) {
|
||||
if (line.startsWith("@@"))
|
||||
return accent
|
||||
if (line.startsWith("+++") || line.startsWith("---"))
|
||||
return textDim
|
||||
if (line.startsWith("+"))
|
||||
return ok
|
||||
if (line.startsWith("-"))
|
||||
return err
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The model's reasoning stream, collapsed by default: it is context, not the
|
||||
// answer.
|
||||
Rectangle {
|
||||
id: block
|
||||
property string text: ""
|
||||
property bool complete: false
|
||||
property bool expanded: false
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad
|
||||
opacity: 0.9
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
leftMargin: Theme.pad
|
||||
rightMargin: Theme.pad
|
||||
topMargin: Theme.pad / 2
|
||||
}
|
||||
spacing: 2
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: block.expanded ? "▾" : "▸"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Text {
|
||||
text: block.complete ? "thinking" : "thinking…"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
visible: !block.expanded
|
||||
text: block.text.replace(/\s+/g, " ").trim()
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: block.expanded = !block.expanded
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
visible: block.expanded
|
||||
text: block.text
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.italic: true
|
||||
wrapMode: Text.Wrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The engine's todo list (on_todo), rendered as a small checklist. The text
|
||||
// is markdown-ish: "- [x] thing" lines.
|
||||
ColumnLayout {
|
||||
id: panel
|
||||
property string text: ""
|
||||
|
||||
function entries() {
|
||||
return text.split("\n")
|
||||
.map(function (l) { return l.trim() })
|
||||
.filter(function (l) { return l.length > 0 })
|
||||
.map(function (l) {
|
||||
const done = /^[-*]\s*\[[xX]\]/.test(l) || /^\[[xX]\]/.test(l)
|
||||
const open = /^[-*]\s*\[\s*\]/.test(l) || /^\[\s*\]/.test(l)
|
||||
return { done: done, todo: open, label: l.replace(/^[-*]?\s*\[[xX ]?\]\s*/, "").replace(/^[-*]\s*/, "") }
|
||||
})
|
||||
}
|
||||
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
text: "Plan"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: panel.entries()
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
text: modelData.done ? "✓" : "○"
|
||||
color: modelData.done ? Theme.ok : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.label
|
||||
color: modelData.done ? Theme.textFaint : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.strikeout: modelData.done
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: panel.entries().length === 0
|
||||
text: "no plan yet"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// A tool call: what ran, whether it is still running, and what came back.
|
||||
// Long output stays folded until asked for, diff lines are colored.
|
||||
Rectangle {
|
||||
id: card
|
||||
property string name: ""
|
||||
property string state: "pending" // pending | ready | running | done | denied
|
||||
property string detail: ""
|
||||
property string output: ""
|
||||
property int exitCode: 0
|
||||
property bool hasResult: false
|
||||
property bool expanded: false
|
||||
|
||||
readonly property bool running: state === "pending" || state === "ready" || state === "running"
|
||||
readonly property color stateColor: state === "denied" ? Theme.err
|
||||
: running ? Theme.warn
|
||||
: hasResult && exitCode !== 0 ? Theme.err
|
||||
: Theme.ok
|
||||
readonly property string stateText: state === "pending" ? "reading"
|
||||
: state === "ready" ? "queued"
|
||||
: state === "running" ? "running"
|
||||
: state === "denied" ? "denied"
|
||||
: hasResult && exitCode !== 0 ? "failed" : "done"
|
||||
|
||||
function lines() {
|
||||
const all = output.split("\n")
|
||||
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
|
||||
}
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: expanded ? Theme.stateColor : Theme.border
|
||||
implicitHeight: layout.implicitHeight + Theme.pad
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
RowLayout {
|
||||
spacing: Theme.gap
|
||||
BusyIndicator {
|
||||
visible: card.running
|
||||
running: card.running
|
||||
implicitWidth: 16
|
||||
implicitHeight: 16
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
Rectangle {
|
||||
visible: !card.running
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
width: 6
|
||||
height: 6
|
||||
radius: 3
|
||||
color: card.stateColor
|
||||
}
|
||||
Text {
|
||||
text: card.name
|
||||
color: Theme.text
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
font.bold: true
|
||||
}
|
||||
Rectangle {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
radius: Theme.radiusSm
|
||||
color: "transparent"
|
||||
border.width: 1
|
||||
border.color: card.stateColor
|
||||
implicitWidth: chip.implicitWidth + 10
|
||||
implicitHeight: chip.implicitHeight + 2
|
||||
Text {
|
||||
id: chip
|
||||
anchors.centerIn: parent
|
||||
text: card.stateText + (card.hasResult ? " · exit " + card.exitCode : "")
|
||||
color: card.stateColor
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: card.detail.replace(/\s+/g, " ").trim()
|
||||
color: Theme.textFaint
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
ToolButton {
|
||||
implicitWidth: 26
|
||||
implicitHeight: 22
|
||||
visible: card.output.length > 0
|
||||
contentItem: Text {
|
||||
text: card.expanded ? "▾" : "▸"
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: card.expanded = !card.expanded
|
||||
}
|
||||
}
|
||||
|
||||
// Folded view: the first line of the result, so a done card still says
|
||||
// something without being opened.
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: !card.expanded && card.output.length > 0
|
||||
text: {
|
||||
const l = card.output.split("\n")
|
||||
return l[0] + (l.length > 1 ? " …" : "")
|
||||
}
|
||||
elide: Text.ElideRight
|
||||
color: Theme.textFaint
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: card.expanded && card.output.length > 0
|
||||
spacing: 0
|
||||
Repeater {
|
||||
model: card.expanded ? card.lines() : []
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: modelData.length ? modelData : " "
|
||||
color: Theme.lineColor(modelData)
|
||||
font.family: Theme.mono
|
||||
font.pixelSize: Theme.monoSize
|
||||
wrapMode: Text.NoWrap
|
||||
textFormat: Text.PlainText
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.topMargin: 4
|
||||
visible: card.output.split("\n").length > 500
|
||||
text: "output truncated for the view"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user