refactor: archive GUI clients; keep minimal TUI with hooks/API
Move Qt/Tauri/full Web SPA under archive/, ship hermit as CLI/TUI plus libcstcore and cst-webui/1 HTTP+SSE (turn lifecycle hooks, capabilities). Co-authored-by: Cursor <cursoragent@cursor.com>
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,152 @@
|
||||
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
|
||||
objectName: "transcript"
|
||||
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
|
||||
images: model.images !== undefined ? model.images : ""
|
||||
/* html comes from the streaming renderer (Main.qml) or,
|
||||
* for a replayed session, from here once. */
|
||||
html: model.html.length > 0 ? model.html
|
||||
: (model.role === "agent" || model.role === "error") &&
|
||||
model.complete && model.text.length > 0
|
||||
? agent.renderMarkdown(model.text) : ""
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: thinkComp
|
||||
ThinkBlock {
|
||||
text: model.text
|
||||
complete: model.complete
|
||||
}
|
||||
}
|
||||
Component {
|
||||
id: toolComp
|
||||
ToolCard {
|
||||
objectName: "toolCard"
|
||||
name: model.name
|
||||
state: model.state
|
||||
detail: model.detail
|
||||
output: model.output
|
||||
exitCode: model.exitCode
|
||||
hasResult: model.complete
|
||||
images: model.images !== undefined ? model.images : ""
|
||||
}
|
||||
}
|
||||
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 {
|
||||
id: jump
|
||||
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
|
||||
contentItem: ThemeIcon {
|
||||
name: "go-down"
|
||||
glyph: "↓"
|
||||
size: 20
|
||||
color: Theme.text
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: width / 2
|
||||
color: jump.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
onClicked: {
|
||||
view.stickBottom = true
|
||||
list.positionViewAtEnd()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Dialogs
|
||||
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.
|
||||
//
|
||||
// Files arrive three ways -- the clip picker, a drop on the window (Main.qml)
|
||||
// and an image on the clipboard (Ctrl+V) -- and all of them end up in the
|
||||
// engine's pending list, which the next prompt carries.
|
||||
Rectangle {
|
||||
id: root
|
||||
objectName: "composer"
|
||||
property bool running: false
|
||||
property bool compact: false
|
||||
property string note: "" // a failure, or what was just attached
|
||||
property var attachments: [] // AgentCore::pending, "name (mime, 12 KB)"
|
||||
property alias text: input.text
|
||||
signal submitted(string text)
|
||||
|
||||
function submit() {
|
||||
const t = input.text.trim()
|
||||
if (!t.length)
|
||||
return
|
||||
input.text = ""
|
||||
submitted(t)
|
||||
}
|
||||
|
||||
/* A file: from the picker, or a URL dropped on the window. */
|
||||
function localPath(url) {
|
||||
let s = String(url)
|
||||
// A phone's picker (SAF) hands over content:// URIs: they are not
|
||||
// paths and must not be decoded -- the app copies what they point at
|
||||
// into its own directory before the engine sees it (see
|
||||
// app::materializeAttachment).
|
||||
if (s.startsWith("content://"))
|
||||
return s
|
||||
if (s.startsWith("file://"))
|
||||
s = s.substring(7)
|
||||
/* Windows keeps its drive letter after the slash. */
|
||||
if (/^\/[A-Za-z]:/.test(s))
|
||||
s = s.substring(1)
|
||||
return decodeURIComponent(s)
|
||||
}
|
||||
|
||||
function attachPath(path) {
|
||||
const r = agent.attachFile(path)
|
||||
note = r.length ? r : ""
|
||||
}
|
||||
|
||||
radius: Theme.radius
|
||||
color: Theme.surface
|
||||
border.width: 1
|
||||
border.color: input.activeFocus ? Theme.accentDim : Theme.border
|
||||
/* One pad of frame on every side of the column: with the top margin
|
||||
* alone the last row (the clip and Send) hung over the lower border,
|
||||
* which is what put the clip on the frame instead of inside it. */
|
||||
implicitHeight: layout.implicitHeight + Theme.pad * 2
|
||||
|
||||
ColumnLayout {
|
||||
id: layout
|
||||
anchors {
|
||||
fill: parent
|
||||
margins: Theme.pad
|
||||
}
|
||||
spacing: 6
|
||||
|
||||
// What the next prompt will carry, in the engine's own words.
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
visible: root.attachments.length > 0
|
||||
spacing: 6
|
||||
|
||||
Repeater {
|
||||
model: root.attachments
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
required property var modelData
|
||||
required property int index
|
||||
|
||||
objectName: "attachChip" + chip.index
|
||||
implicitWidth: chipText.implicitWidth + 14
|
||||
implicitHeight: 24
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
text: String(chip.modelData)
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button {
|
||||
id: clearAtt
|
||||
objectName: "clearAttachments"
|
||||
text: "clear all"
|
||||
implicitHeight: 24
|
||||
onClicked: {
|
||||
agent.clearPending()
|
||||
root.note = ""
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: clearAtt.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: clearAtt.text
|
||||
color: clearAtt.hovered ? Theme.text : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: root.note.length > 0
|
||||
text: root.note
|
||||
color: Theme.warn
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
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
|
||||
? "Send a correction now — Esc pauses execution"
|
||||
: (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: true
|
||||
|
||||
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
|
||||
}
|
||||
return
|
||||
}
|
||||
/* An image has nothing the editor could paste, so Ctrl+V
|
||||
* becomes an attachment there; text stays with the
|
||||
* editor's own paste. */
|
||||
if ((ev.modifiers & Qt.ControlModifier) && ev.key === Qt.Key_V &&
|
||||
agent.clipboardHasImage()) {
|
||||
root.note = agent.attachClipboard()
|
||||
ev.accepted = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
id: attach
|
||||
objectName: "attachButton"
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? Theme.touchTarget : 40
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
enabled: !root.running
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Attach a file — or drop one on the window, or paste an image"
|
||||
contentItem: ThemeIcon {
|
||||
name: "mail-attachment"
|
||||
glyph: "📎"
|
||||
size: 20
|
||||
color: attach.enabled ? (attach.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: attach.hovered && attach.enabled ? Theme.surfaceHi
|
||||
: "transparent"
|
||||
}
|
||||
onClicked: picker.open()
|
||||
}
|
||||
|
||||
Button {
|
||||
id: action
|
||||
objectName: "sendButton"
|
||||
Layout.alignment: Qt.AlignBottom
|
||||
implicitWidth: root.compact ? 68 : 88
|
||||
implicitHeight: Theme.touchTarget + 4
|
||||
text: root.running ? (input.text.trim().length ? "Redirect" : "Stop") : "Send"
|
||||
enabled: root.running || input.text.trim().length > 0
|
||||
onClicked: root.running && !input.text.trim().length ? 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 ? Theme.accentInk : 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: {
|
||||
if (root.running)
|
||||
return "working — Esc cancels the turn"
|
||||
let s = "Enter sends · Shift+Enter newline · Esc stops · the clip attaches a file, Ctrl+V an image"
|
||||
if (root.attachments.length > 0 && agent.seesImages() === 0)
|
||||
s += " · this model is text-only, images arrive as notes"
|
||||
return s
|
||||
}
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
FileDialog {
|
||||
id: picker
|
||||
title: "Attach a file"
|
||||
fileMode: FileDialog.OpenFile
|
||||
onAccepted: root.attachPath(root.localPath(selectedFile))
|
||||
}
|
||||
}
|
||||
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,269 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// The files a message or a tool call carries.
|
||||
//
|
||||
// A picture is a thumbnail from the session's own store (image://cstattach/,
|
||||
// app/src/ui/SessionImages.cpp) and opens with whatever the system uses for
|
||||
// images when it is tapped. A file the agent delivered (deliver_file) is a card
|
||||
// with the name, the type, the size and two buttons: open and share. The names
|
||||
// are the ones the engine wrote next to the session -- a card never invents a
|
||||
// path.
|
||||
//
|
||||
// One line per file, written by AgentCore.cpp (filesText):
|
||||
// <kind>|<stored name>|<name>|<origin>|<mime>|<bytes>|<w>x<h>
|
||||
// A session saved by an older version wrote "<stored name>|<name>|<origin>|
|
||||
// <w>x<h>" for pictures; both shapes are read here.
|
||||
Flow {
|
||||
id: strip
|
||||
objectName: "imageStrip"
|
||||
/* A row carries this as text: a ListModel role cannot hold a list. */
|
||||
property string text: ""
|
||||
property var files: decode(text)
|
||||
/* The last refusal, so a tap that cannot work says why (the header's own
|
||||
* status line is the engine's). */
|
||||
property string errorText: ""
|
||||
spacing: 6
|
||||
|
||||
function decode(t) {
|
||||
const out = []
|
||||
const lines = String(t).split("\n")
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!lines[i].length)
|
||||
continue
|
||||
const f = lines[i].split("|")
|
||||
if (!f[0].length)
|
||||
continue
|
||||
const old = f.length < 7 // a picture line from an old session
|
||||
const dim = String(old ? f[3] : f[6]).split("x")
|
||||
let w = 0, h = 0
|
||||
if (dim.length === 2) {
|
||||
w = parseInt(dim[0], 10) || 0
|
||||
h = parseInt(dim[1], 10) || 0
|
||||
}
|
||||
const rel = old ? f[0] : f[1]
|
||||
out.push({ kind: old ? "img" : f[0],
|
||||
rel: rel,
|
||||
name: (old ? f[1] : f[2]) || rel,
|
||||
origin: (old ? f[2] : f[3]) || "",
|
||||
mime: old ? "image/*" : (f[4] || ""),
|
||||
bytes: old ? 0 : (parseInt(f[5], 10) || 0),
|
||||
w: w, h: h,
|
||||
url: "image://cstattach/" + rel })
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function humanSize(n) {
|
||||
if (!n || n <= 0)
|
||||
return ""
|
||||
if (n >= 1024 * 1024)
|
||||
return (n / (1024 * 1024)).toFixed(1) + " MB"
|
||||
if (n >= 1024)
|
||||
return Math.round(n / 1024) + " KB"
|
||||
return n + " B"
|
||||
}
|
||||
|
||||
/* The two things a file card does. The engine answers with "" when it
|
||||
* worked (it puts the line in the header itself) or a reason. */
|
||||
function openFile(rel) {
|
||||
errorText = agent.openArtifact(rel)
|
||||
}
|
||||
function shareFile(rel) {
|
||||
errorText = agent.shareArtifact(rel)
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: strip.files
|
||||
|
||||
delegate: Rectangle {
|
||||
id: cell
|
||||
required property var modelData
|
||||
required property int index
|
||||
readonly property bool isFile: String(modelData.kind) !== "img"
|
||||
readonly property bool hovered: mouse.containsMouse
|
||||
/* Images keep their own shape, capped; a file card is a fixed
|
||||
* width so a long name cannot stretch the transcript. */
|
||||
readonly property real ratio: {
|
||||
const w = Number(modelData.w), h = Number(modelData.h)
|
||||
return (w > 0 && h > 0) ? w / h : 1.5
|
||||
}
|
||||
readonly property int cap: 150
|
||||
|
||||
objectName: isFile ? ("fileCard" + index) : ("imageThumb" + index)
|
||||
width: isFile ? 300
|
||||
: Math.max(56, Math.min(Math.round(cap * ratio), 320))
|
||||
height: isFile ? 52
|
||||
: Math.max(40, Math.min(cap, Math.round(width / ratio)))
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: hovered ? Theme.accentDim : Theme.border
|
||||
clip: true
|
||||
|
||||
MouseArea {
|
||||
id: mouse
|
||||
anchors.fill: parent
|
||||
hoverEnabled: true
|
||||
acceptedButtons: Qt.NoButton
|
||||
}
|
||||
|
||||
// --- a picture: tap it to look at it ---------------------------
|
||||
Image {
|
||||
id: pic
|
||||
anchors.fill: parent
|
||||
anchors.margins: 2
|
||||
visible: !cell.isFile && status === Image.Ready
|
||||
source: String(cell.modelData.url)
|
||||
sourceSize.width: 512
|
||||
sourceSize.height: 512
|
||||
fillMode: Image.PreserveAspectFit
|
||||
asynchronous: false
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
width: parent.width - 8
|
||||
visible: !cell.isFile && pic.status === Image.Error
|
||||
text: String(cell.modelData.name)
|
||||
elide: Text.ElideMiddle
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
enabled: !cell.isFile
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onClicked: strip.openFile(String(cell.modelData.rel))
|
||||
}
|
||||
|
||||
// --- a delivered file: name, type, open, share -----------------
|
||||
RowLayout {
|
||||
id: row
|
||||
visible: cell.isFile
|
||||
anchors.fill: parent
|
||||
anchors.leftMargin: 8
|
||||
anchors.rightMargin: 6
|
||||
spacing: 6
|
||||
|
||||
/* The extension stands in for an icon: no theme lookup, and a
|
||||
* ZIP that says ZIP is more use than a generic sheet. */
|
||||
Rectangle {
|
||||
implicitWidth: 40
|
||||
implicitHeight: 30
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.surfaceHi
|
||||
Text {
|
||||
anchors.centerIn: parent
|
||||
text: {
|
||||
const n = String(cell.modelData.name)
|
||||
const dot = n.lastIndexOf(".")
|
||||
if (dot < 0 || dot === n.length - 1)
|
||||
return "FILE"
|
||||
return n.slice(dot + 1, dot + 5).toUpperCase()
|
||||
}
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 0
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: String(cell.modelData.name)
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: {
|
||||
const m = cell.modelData
|
||||
let s = m.mime ? String(m.mime) : ""
|
||||
const size = strip.humanSize(m.bytes)
|
||||
if (size)
|
||||
s += (s ? " · " : "") + size
|
||||
if (m.origin && m.origin !== "user" && m.origin !== "tool")
|
||||
s += (s ? " · " : "") + m.origin
|
||||
return s
|
||||
}
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "fileOpen" + cell.index
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: strip.openFile(String(cell.modelData.rel))
|
||||
contentItem: Text {
|
||||
text: "Open"
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "fileShare" + cell.index
|
||||
implicitWidth: Theme.touchTarget
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: strip.shareFile(String(cell.modelData.rel))
|
||||
contentItem: Text {
|
||||
text: "Share"
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.delay: 400
|
||||
ToolTip.text: {
|
||||
const m = cell.modelData
|
||||
let s = String(m.name)
|
||||
if (m.w > 0 && m.h > 0)
|
||||
s += " · " + m.w + "×" + m.h
|
||||
if (m.mime && m.kind !== "img")
|
||||
s += " · " + m.mime
|
||||
return s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* A tap that could not work says why, right where the card is: a phone
|
||||
* with nothing installed for the type is the common case ("no app on the
|
||||
* phone opens report.md"), and a silence there looks like a dead button. */
|
||||
Text {
|
||||
objectName: "fileError"
|
||||
visible: strip.errorText.length > 0
|
||||
width: strip.width
|
||||
wrapMode: Text.WordWrap
|
||||
text: strip.errorText
|
||||
color: Theme.err
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,717 @@
|
||||
// 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
|
||||
TaskPanel { id: taskPanel; anchors.centerIn: parent }
|
||||
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: "", html: "", complete: false,
|
||||
name: "", callId: "", state: state, detail: "", output: "",
|
||||
exitCode: 0, images: "" })
|
||||
}
|
||||
|
||||
// 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 open card of this call, -1 when none. The id is what tells two calls
|
||||
// of the same tool apart (pairing by name left the first card running
|
||||
// forever); a call without one -- an old text-protocol session -- falls
|
||||
// back to the name.
|
||||
function findTool(callId, name) {
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind !== "tool" || r.complete)
|
||||
continue
|
||||
if (callId.length) {
|
||||
if (r.callId === callId)
|
||||
return i
|
||||
continue /* another call: its card is further back */
|
||||
}
|
||||
if (r.callId.length)
|
||||
continue
|
||||
if (r.name === name)
|
||||
return i
|
||||
return -1
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
function openTool(callId, name, state) {
|
||||
closeOpenRows("tool")
|
||||
addRow("tool", "tool", state)
|
||||
transcript.setProperty(lastIndex(), "name", name)
|
||||
transcript.setProperty(lastIndex(), "callId", callId)
|
||||
return lastIndex()
|
||||
}
|
||||
|
||||
function submit(text) {
|
||||
transcript.append({ kind: "message", role: "user", text: text, html: "", complete: true,
|
||||
name: "", callId: "", state: "", detail: "", output: "",
|
||||
exitCode: 0, images: "" })
|
||||
agent.send(text)
|
||||
}
|
||||
|
||||
// A turn is over: nothing else is coming, so nothing may keep spinning.
|
||||
function closeAllRows() {
|
||||
for (let i = 0; i < transcript.count; i++) {
|
||||
const r = transcript.get(i)
|
||||
if (r.complete)
|
||||
continue
|
||||
if (r.kind === "tool" && (r.state === "pending" || r.state === "ready" ||
|
||||
r.state === "running"))
|
||||
transcript.setProperty(i, "state", "unfinished")
|
||||
transcript.setProperty(i, "complete", true)
|
||||
}
|
||||
}
|
||||
|
||||
// Markdown while it streams: the open answer is re-rendered with the
|
||||
// engine's own renderer (the timer below), so the text fills in formatted
|
||||
// instead of turning into markdown when the turn ends.
|
||||
function renderStreaming() {
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind !== "message" || r.role !== "agent" || r.complete)
|
||||
continue
|
||||
const html = agent.renderMarkdown(r.text)
|
||||
if (html !== r.html)
|
||||
transcript.setProperty(i, "html", html)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 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, temperature, topP, maxTokens) {
|
||||
/* The parameters are stored first: useProvider() then picks up what
|
||||
* was remembered for whichever provider it ends on. */
|
||||
const perr = agent.setGenParams(provider, temperature, topP, maxTokens)
|
||||
const err = perr.length ? perr : agent.useProvider(provider, model, key)
|
||||
settings.note = err.length ? err
|
||||
: "using " + agent.provider + " / " + agent.model
|
||||
settings.choices = agent.modelChoices(provider)
|
||||
settings.syncPick()
|
||||
}
|
||||
|
||||
/* The engine opens the workspace's last session (cst_agent_open()), so the
|
||||
* window is created with a transcript already in it: draw it, or the pane
|
||||
* starts empty while the sidebar points at the very session it belongs to.
|
||||
* A switch draws it the same way (the sessionChanged handler below). */
|
||||
Component.onCompleted: {
|
||||
win.reloadSessions()
|
||||
win.replay()
|
||||
}
|
||||
|
||||
// ---- the shell -------------------------------------------------------
|
||||
/* A plain Rectangle, not a ToolBar. A Controls ToolBar hands its children
|
||||
* to a style-owned contentItem, and on this phone that item sat 35 px below
|
||||
* the bar itself -- header y 0 h 42 with its gear at y 56 -- so the gear
|
||||
* was drawn, and clicked, outside the header, over the sidebar. A
|
||||
* Rectangle puts its children where it is (checked with --uitest on the
|
||||
* device and on both styles at home). */
|
||||
header: Rectangle {
|
||||
id: appBar
|
||||
objectName: "appBar"
|
||||
implicitHeight: Theme.headerHeight
|
||||
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: ThemeIcon {
|
||||
name: "application-menu"
|
||||
glyph: "☰"
|
||||
size: 22
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
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: ThemeIcon {
|
||||
name: "settings-configure"
|
||||
glyph: "⚙"
|
||||
size: 22
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
ToolButton {
|
||||
objectName: "taskPanelButton"
|
||||
text: "Tasks"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: taskPanel.open()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
objectName: "chatView"
|
||||
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, temperature, topP, maxTokens) {
|
||||
win.applySettings(provider, model, key, temperature, topP, maxTokens)
|
||||
}
|
||||
}
|
||||
|
||||
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 = ""
|
||||
}
|
||||
}
|
||||
/* The back key a phone has instead of a mouse: it closes what is open, one
|
||||
* layer at a time -- the dialog on top, then the sliding sidebar. With
|
||||
* nothing open the key goes on to the platform, and a phone leaves the
|
||||
* window (Qt finishes the activity), which is right for the last layer.
|
||||
*
|
||||
* Which layer that is, and whether there is one, lives in one place: the
|
||||
* C++ side (app/src/ui/BackKey.cpp) reads `backLayer()` on every Back key
|
||||
* and calls `stepBack()` -- not a QML Shortcut, which a modal popup never
|
||||
* lets through. --uitest reads the same two functions. */
|
||||
function backLayer() {
|
||||
if (settings.visible)
|
||||
return "settings"
|
||||
if (ask.visible)
|
||||
return "ask"
|
||||
if (consent.visible)
|
||||
return "consent"
|
||||
if (win.navOpen)
|
||||
return "drawer"
|
||||
return ""
|
||||
}
|
||||
|
||||
/* One layer back. The dialogs are closed on the next turn of the event
|
||||
* loop: closing one from inside the key event leaves its modal overlay up
|
||||
* in this Qt (the dialog says it is closed, the overlay still eats every
|
||||
* click after it), which --uitest walks straight into. */
|
||||
function stepBack() {
|
||||
if (settings.visible)
|
||||
Qt.callLater(settings.close)
|
||||
else if (ask.visible)
|
||||
Qt.callLater(ask.close)
|
||||
else if (consent.visible)
|
||||
Qt.callLater(consent.close)
|
||||
else if (win.navOpen)
|
||||
win.navOpen = false
|
||||
}
|
||||
|
||||
|
||||
// ---- engine -> UI ----------------------------------------------------
|
||||
/* The signal handlers only forward: every one of them has a function
|
||||
* here, so the same path can be driven from --uitest (which is how the
|
||||
* two-calls-of-one-tool case stays fixed). */
|
||||
function engineAssistant(text) { win.appendDelta("message", "agent", text) }
|
||||
function engineThink(text) { win.appendDelta("think", "agent", text) }
|
||||
|
||||
function engineToolEvent(phase, id, name, detail) {
|
||||
win.closeOpenRows("tool")
|
||||
let i = win.findTool(id, name)
|
||||
if (phase === "open" || phase === "arg") {
|
||||
if (i < 0)
|
||||
i = win.openTool(id, 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(id, name, phase)
|
||||
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
|
||||
transcript.setProperty(i, "detail", detail)
|
||||
return
|
||||
}
|
||||
// done | denied
|
||||
if (i < 0)
|
||||
i = win.openTool(id, name, phase)
|
||||
transcript.setProperty(i, "state", phase)
|
||||
if (detail.length && transcript.get(i).output.length === 0)
|
||||
transcript.setProperty(i, "output", detail)
|
||||
}
|
||||
|
||||
function engineToolResult(id, name, exitCode, output) {
|
||||
let i = win.findTool(id, name)
|
||||
if (i < 0) /* a result whose call is not in the view: its own card */
|
||||
i = win.openTool(id, name, "done")
|
||||
transcript.setProperty(i, "output", output)
|
||||
transcript.setProperty(i, "exitCode", exitCode)
|
||||
transcript.setProperty(i, "state", "done")
|
||||
transcript.setProperty(i, "complete", true)
|
||||
}
|
||||
|
||||
function engineTurnFinished(rc, err) {
|
||||
win.renderStreaming(); /* one last render while the row is still open */
|
||||
win.closeAllRows()
|
||||
if (err.length)
|
||||
transcript.append({ kind: "note", role: "error", text: err, html: "",
|
||||
complete: true, name: "", callId: "", state: "error",
|
||||
detail: "", output: "", exitCode: 0 })
|
||||
win.statusText = ""
|
||||
}
|
||||
|
||||
/* The images the prompt just carried (AgentCore::promptImages): the
|
||||
* composer has appended the user row, so they attach to it. */
|
||||
function enginePromptImages(images) {
|
||||
if (!images.length)
|
||||
return
|
||||
for (let i = transcript.count - 1; i >= 0; i--) {
|
||||
const r = transcript.get(i)
|
||||
if (r.kind === "message" && r.role === "user") {
|
||||
transcript.setProperty(i, "images", images)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Files a tool produced, on the card of the call that made them. The
|
||||
* engine reports the result first and the files after it, so the card is
|
||||
* normally already complete by the time they arrive -- findTool() only
|
||||
* looks at cards still running, which is why this looks for the call id
|
||||
* itself (and falls back to findTool for a call that has no id). */
|
||||
function engineToolImages(callId, images) {
|
||||
if (!images.length)
|
||||
return
|
||||
let i = -1
|
||||
if (callId.length) {
|
||||
for (let k = transcript.count - 1; k >= 0; k--) {
|
||||
const r = transcript.get(k)
|
||||
if (r.kind === "tool" && r.callId === callId) {
|
||||
i = k
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
i = win.findTool("", "")
|
||||
}
|
||||
if (i >= 0)
|
||||
transcript.setProperty(i, "images", images)
|
||||
}
|
||||
|
||||
/* For --uitest: one transcript row, or null. */
|
||||
function rowAt(i) {
|
||||
return (i >= 0 && i < transcript.count) ? transcript.get(i) : null
|
||||
}
|
||||
|
||||
/* For --uitest: the same row as JSON (a QObject's return value does not
|
||||
* survive QMetaObject::invokeMethod, a string does). */
|
||||
function rowJson(i) {
|
||||
return JSON.stringify(rowAt(i))
|
||||
}
|
||||
|
||||
/* While a turn runs the open answer is re-rendered a few times a second:
|
||||
* markdown text fills in formatted rather than at the end of the turn. */
|
||||
Timer {
|
||||
interval: 120
|
||||
repeat: true
|
||||
running: agent.running
|
||||
onTriggered: win.renderStreaming()
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: agent
|
||||
|
||||
function onSessionChanged() { win.reloadSessions(); win.replay() }
|
||||
function onSessionListChanged() { win.reloadSessions() }
|
||||
|
||||
function onAssistantDelta(text) { win.engineAssistant(text) }
|
||||
function onThinkDelta(text) { win.engineThink(text) }
|
||||
|
||||
function onStatusChanged(text) { win.statusText = text }
|
||||
|
||||
function onToolEvent(phase, id, name, detail) {
|
||||
win.engineToolEvent(phase, id, name, detail)
|
||||
}
|
||||
|
||||
function onToolResult(id, name, exitCode, output) {
|
||||
win.engineToolResult(id, name, exitCode, output)
|
||||
}
|
||||
|
||||
function onToolImages(callId, images) {
|
||||
win.engineToolImages(callId, images)
|
||||
}
|
||||
|
||||
function onPromptImages(images) {
|
||||
win.enginePromptImages(images)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
/* Nobody answered (the app was in the background, or the dialog sat
|
||||
* there too long): the turn went on without it, so put the dialog
|
||||
* away instead of leaving a question that is no longer being asked. */
|
||||
function onPermissionExpired(tool) { consent.close() }
|
||||
function onQuestionExpired() { ask.close() }
|
||||
|
||||
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
||||
|
||||
function onRunningChanged() {
|
||||
if (!agent.running)
|
||||
win.closeAllRows()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
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
|
||||
property string images: ""
|
||||
|
||||
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: ThemeIcon {
|
||||
name: "edit-copy"
|
||||
glyph: "⧉"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.text : Theme.textFaint
|
||||
}
|
||||
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) }
|
||||
}
|
||||
|
||||
// What the prompt carried: pictures from the store, not their names.
|
||||
ImageStrip {
|
||||
Layout.fillWidth: true
|
||||
visible: card.images.length > 0
|
||||
text: card.images
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,106 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// One stored session in the sidebar: its name (a rename, else its first
|
||||
// prompt), how much is in it and when it was last written, and a way to drop
|
||||
// it. The open one is marked. A file of its own because the sidebar lists them
|
||||
// through a Repeater -- an inline component would not see modelData there.
|
||||
Rectangle {
|
||||
id: srow
|
||||
property var entry: null
|
||||
readonly property bool open: entry !== null && entry.current === true
|
||||
|
||||
objectName: "sessionRow:" + (entry !== null ? entry.id : "")
|
||||
implicitHeight: rowCol.implicitHeight + 8
|
||||
radius: Theme.radiusSm
|
||||
color: srow.open ? Theme.accentDim
|
||||
: rowHover.hovered ? Theme.surfaceAlt : "transparent"
|
||||
border.width: 1
|
||||
border.color: srow.open ? Theme.accent : "transparent"
|
||||
|
||||
signal activated(string id)
|
||||
signal removeRequested(string id)
|
||||
|
||||
// "3h ago"; the entries carry ms since the epoch.
|
||||
function ago(ms) {
|
||||
if (!ms)
|
||||
return "never"
|
||||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||
if (s < 60)
|
||||
return "just now"
|
||||
if (s < 3600)
|
||||
return Math.floor(s / 60) + "m ago"
|
||||
if (s < 86400)
|
||||
return Math.floor(s / 3600) + "h ago"
|
||||
return Math.floor(s / 86400) + "d ago"
|
||||
}
|
||||
|
||||
HoverHandler { id: rowHover }
|
||||
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: srow.activated(srow.entry.id)
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
spacing: 2
|
||||
|
||||
ColumnLayout {
|
||||
id: rowCol
|
||||
Layout.fillWidth: true
|
||||
Layout.leftMargin: 4
|
||||
spacing: 0
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: srow.entry !== null && srow.entry.title.length ? srow.entry.title
|
||||
: srow.entry !== null && srow.entry.firstUser.length ? srow.entry.firstUser
|
||||
: "(no prompt yet)"
|
||||
color: srow.open ? Theme.accentText : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideRight
|
||||
text: {
|
||||
if (srow.entry === null)
|
||||
return ""
|
||||
const n = srow.entry.messages
|
||||
const when = srow.ago(srow.entry.mtime)
|
||||
return n > 0 ? n + (n === 1 ? " message · " : " messages · ") + when
|
||||
: when
|
||||
}
|
||||
color: srow.open ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
ToolButton {
|
||||
objectName: "sessionDelete:" + (srow.entry !== null ? srow.entry.id : "")
|
||||
implicitWidth: 26
|
||||
implicitHeight: 26
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Delete this session and its transcript"
|
||||
contentItem: ThemeIcon {
|
||||
name: "edit-delete"
|
||||
glyph: "×"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.err
|
||||
: srow.open ? Theme.accentTextDim : Theme.textFaint
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: srow.removeRequested(srow.entry.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,739 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
// Provider, model and key: what the engine needs before it can talk to a model
|
||||
// at all. Everything here goes through AgentCore, which moves the engine's own
|
||||
// config (its session stays where it is) and remembers the choice. A key is
|
||||
// verified against the provider before it is saved and is never read back into
|
||||
// this window -- the dialog only ever says where the key it will use comes
|
||||
// from.
|
||||
//
|
||||
// Three things live under "Advanced options", because they are for the odd
|
||||
// provider or the odd model: the provider's own entry (name, base URL, key
|
||||
// variable, models -- the engine keeps those in <config>/models.json, which is
|
||||
// how a provider the presets do not know is added or an existing one is
|
||||
// pointed somewhere else) and the generation parameters (temperature, top-p,
|
||||
// max tokens), which are remembered per provider.
|
||||
Dialog {
|
||||
id: dlg
|
||||
objectName: "settingsDialog"
|
||||
parent: Overlay.overlay
|
||||
anchors.centerIn: parent
|
||||
modal: true
|
||||
closePolicy: Popup.CloseOnEscape
|
||||
padding: Theme.pad
|
||||
|
||||
property var providers: [] // AgentCore::providers()
|
||||
property var choices: [] // AgentCore::modelChoices(picked)
|
||||
property string picked: ""
|
||||
property string model: ""
|
||||
property string note: "" // what the last action answered
|
||||
property string keyEnv: ""
|
||||
property string keyFrom: ""
|
||||
property bool hasKey: false
|
||||
property bool advanced: false
|
||||
property string modelFilter: "" // narrows a long model list
|
||||
/* The advanced form: the picked provider's entry, editable. */
|
||||
property string advName: ""
|
||||
property string advBase: ""
|
||||
property string advKeyEnv: ""
|
||||
property string advModel: ""
|
||||
property string advModels: ""
|
||||
property string tempText: ""
|
||||
property string topPText: ""
|
||||
property string maxTokText: ""
|
||||
|
||||
signal applyRequested(string provider, string model, string key,
|
||||
real temperature, real topP, int maxTokens)
|
||||
|
||||
width: Math.min(660, parent ? parent.width - 32 : 660)
|
||||
height: Math.min(640, parent ? parent.height - 40 : 640)
|
||||
|
||||
/* The Basic style paints a light panel; this window is dark, so the
|
||||
* dialog brings its own surface like every other one here. */
|
||||
background: Rectangle {
|
||||
color: Theme.surface
|
||||
radius: Theme.radius
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
|
||||
// A long provider list (a router lists hundreds) is a wall of chips: the
|
||||
// filter narrows it and the rest is one line ("+N more").
|
||||
readonly property int chipCap: 40
|
||||
readonly property var chips: {
|
||||
const f = modelFilter.trim().toLowerCase()
|
||||
const out = []
|
||||
for (let i = 0; i < choices.length; i++) {
|
||||
const c = String(choices[i])
|
||||
if (!f.length || c.toLowerCase().indexOf(f) >= 0)
|
||||
out.push(c)
|
||||
}
|
||||
return out
|
||||
}
|
||||
readonly property int chipsHidden: Math.max(0, chips.length - chipCap)
|
||||
|
||||
/* "0.2" -> 0.2; anything empty or unreadable -> 0 (the field is not sent,
|
||||
* which is not the same as sending 0). */
|
||||
function num(text) {
|
||||
const v = parseFloat(String(text).replace(",", "."))
|
||||
return isFinite(v) && v > 0 ? v : 0
|
||||
}
|
||||
|
||||
function pickedEntry() {
|
||||
for (let i = 0; i < providers.length; i++)
|
||||
if (providers[i].name === dlg.picked)
|
||||
return providers[i]
|
||||
return null
|
||||
}
|
||||
|
||||
/* Fill it in: the presets, the provider the engine is on now, and the
|
||||
* model that provider last used. */
|
||||
function load(list, provider, currentModel) {
|
||||
providers = list
|
||||
picked = provider
|
||||
model = currentModel
|
||||
note = ""
|
||||
for (let i = 0; i < list.length; i++)
|
||||
if (list[i].current === true)
|
||||
picked = list[i].name
|
||||
choices = picked.length ? agent.modelChoices(picked) : []
|
||||
modelFilter = ""
|
||||
syncPick()
|
||||
}
|
||||
|
||||
/* Where the picked provider's key would come from and what it is set to.
|
||||
* From the entries -- a key itself never reaches the QML side. */
|
||||
function syncPick() {
|
||||
hasKey = false
|
||||
keyFrom = ""
|
||||
keyEnv = ""
|
||||
const p = pickedEntry()
|
||||
if (p) {
|
||||
hasKey = p.hasKey === true
|
||||
keyFrom = p.keyFrom
|
||||
keyEnv = p.keyEnv
|
||||
/* The advanced form describes the picked provider: edit it, or
|
||||
* type another name to add one. */
|
||||
advName = p.name
|
||||
advBase = p.base
|
||||
advKeyEnv = p.keyEnv
|
||||
advModel = p.model
|
||||
advModels = p.custom === true ? agent.modelChoices(p.name).join(", ") : ""
|
||||
}
|
||||
const g = picked.length ? agent.genParams(picked) : null
|
||||
tempText = g && g.temperature > 0 ? String(g.temperature) : ""
|
||||
topPText = g && g.top_p > 0 ? String(g.top_p) : ""
|
||||
maxTokText = g && g.max_tokens > 0 ? String(g.max_tokens) : ""
|
||||
}
|
||||
|
||||
function keyState() {
|
||||
if (hasKey)
|
||||
return keyFrom === "saved" ? "a key is saved · paste another to replace it"
|
||||
: "key from the environment: " + keyFrom
|
||||
return keyEnv.length ? "no key yet — set " + keyEnv + ", or paste one here"
|
||||
: "no key yet — paste one here"
|
||||
}
|
||||
|
||||
function usageLine() {
|
||||
const u = agent.usage()
|
||||
if (!u.requests)
|
||||
return "no tokens spent in this session yet"
|
||||
let s = u.requests + (u.requests === 1 ? " request" : " requests") +
|
||||
" · " + u.prompt + " in · " + u.out + " out"
|
||||
if (u.priced)
|
||||
s += " · " + u.cost.toFixed(4) + " " + u.currency
|
||||
return s
|
||||
}
|
||||
|
||||
/* Write the form's entry into the engine's user file and list it again. */
|
||||
function saveProvider() {
|
||||
/* The fields are the truth here, not the properties: a hand-typed
|
||||
* edit is what the user wants saved. */
|
||||
const name = advNameField.text.trim()
|
||||
const err = agent.addProvider(name, advBaseField.text.trim(),
|
||||
advKeyEnvField.text.trim(),
|
||||
advModelField.text.trim(),
|
||||
advModelsField.text)
|
||||
if (err.length) {
|
||||
note = err
|
||||
return
|
||||
}
|
||||
providers = agent.providers()
|
||||
picked = name
|
||||
choices = agent.modelChoices(name)
|
||||
syncPick()
|
||||
note = "saved " + name + " to models.json — it is in the list above now"
|
||||
}
|
||||
|
||||
function removeProvider() {
|
||||
const typed = advNameField.text.trim()
|
||||
const name = typed.length ? typed : picked
|
||||
const rc = agent.removeProvider(name)
|
||||
if (rc < 0) {
|
||||
note = "could not remove " + name
|
||||
return
|
||||
}
|
||||
providers = agent.providers()
|
||||
note = rc === 0
|
||||
? name + " is not in models.json (a preset of that name stays)"
|
||||
: "removed " + name + " from models.json"
|
||||
if (rc === 1 && providers.length > 0)
|
||||
picked = providers[0].name
|
||||
choices = picked.length ? agent.modelChoices(picked) : []
|
||||
syncPick()
|
||||
}
|
||||
|
||||
/* The form's fields follow the picked provider (and a user edit breaks
|
||||
* the binding, so the push is imperative). */
|
||||
onAdvNameChanged: if (advNameField && advNameField.text !== advName) advNameField.text = advName
|
||||
onAdvBaseChanged: if (advBaseField && advBaseField.text !== advBase) advBaseField.text = advBase
|
||||
onAdvKeyEnvChanged: if (advKeyEnvField && advKeyEnvField.text !== advKeyEnv) advKeyEnvField.text = advKeyEnv
|
||||
onAdvModelChanged: if (advModelField && advModelField.text !== advModel) advModelField.text = advModel
|
||||
onAdvModelsChanged: if (advModelsField && advModelsField.text !== advModels) advModelsField.text = advModels
|
||||
onTempTextChanged: if (tempField && tempField.text !== tempText) tempField.text = tempText
|
||||
onTopPTextChanged: if (topPField && topPField.text !== topPText) topPField.text = topPText
|
||||
onMaxTokTextChanged: if (maxTokField && maxTokField.text !== maxTokText) maxTokField.text = maxTokText
|
||||
|
||||
// One labelled field of the advanced form.
|
||||
component AdvField: ColumnLayout {
|
||||
property string label: ""
|
||||
property alias text: field.text
|
||||
property alias placeholder: field.placeholderText
|
||||
property int hintWidth: 0
|
||||
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
Text {
|
||||
text: parent.label
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
TextField {
|
||||
id: field
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.touchTarget
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: field.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
contentItem: ColumnLayout {
|
||||
objectName: "settingsPanel"
|
||||
spacing: Theme.gap
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Provider, model, key"
|
||||
color: Theme.text
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Keys are stored under <config>/cst-agent/credentials (mode 0600) and are never shown again."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
/* Everything scrolls, so the buttons stay where they are and a long
|
||||
* model list cannot push them out of the dialog. */
|
||||
Flickable {
|
||||
id: body
|
||||
objectName: "settingsBody"
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
Layout.minimumHeight: 120
|
||||
clip: true
|
||||
contentWidth: width
|
||||
contentHeight: bodyCol.implicitHeight
|
||||
boundsBehavior: Flickable.StopAtBounds
|
||||
ScrollBar.vertical: ScrollBar {}
|
||||
|
||||
ColumnLayout {
|
||||
id: bodyCol
|
||||
width: parent.width
|
||||
spacing: Theme.gap
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 2
|
||||
|
||||
Repeater {
|
||||
model: dlg.providers
|
||||
delegate: Rectangle {
|
||||
id: prow
|
||||
required property var modelData
|
||||
readonly property bool selected: dlg.picked === modelData.name
|
||||
|
||||
objectName: "providerRow:" + modelData.name
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: prowCol.implicitHeight + 8
|
||||
radius: Theme.radiusSm
|
||||
color: prow.selected ? Theme.accentDim
|
||||
: prowHover.hovered ? Theme.surfaceAlt
|
||||
: "transparent"
|
||||
border.width: 1
|
||||
border.color: prow.selected ? Theme.accent : "transparent"
|
||||
|
||||
HoverHandler { id: prowHover }
|
||||
MouseArea {
|
||||
anchors.fill: parent
|
||||
onClicked: {
|
||||
dlg.picked = prow.modelData.name
|
||||
modelField.text = prow.modelData.model
|
||||
dlg.choices = agent.modelChoices(prow.modelData.name)
|
||||
dlg.modelFilter = ""
|
||||
dlg.syncPick()
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
id: prowCol
|
||||
anchors {
|
||||
left: parent.left
|
||||
right: parent.right
|
||||
top: parent.top
|
||||
margins: 4
|
||||
}
|
||||
spacing: 0
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: prow.modelData.name
|
||||
color: prow.selected ? Theme.accentText : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: prow.selected
|
||||
}
|
||||
Text {
|
||||
visible: prow.modelData.current === true
|
||||
text: "in use"
|
||||
color: prow.selected ? Theme.okOnAccent : Theme.ok
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: prow.modelData.custom === true
|
||||
? "custom" : prow.modelData.source
|
||||
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
elide: Text.ElideMiddle
|
||||
text: prow.modelData.base + " · " +
|
||||
(prow.modelData.hasKey === true
|
||||
? (prow.modelData.keyFrom === "saved"
|
||||
? "key saved"
|
||||
: "key from " + prow.modelData.keyFrom)
|
||||
: (prow.modelData.keyEnv.length
|
||||
? "no key (" + prow.modelData.keyEnv + ")"
|
||||
: "no key"))
|
||||
color: prow.selected ? Theme.accentTextDim : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Model"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
TextField {
|
||||
id: modelField
|
||||
objectName: "settingsModel"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.model
|
||||
implicitHeight: Theme.touchTarget
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "model name"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: modelField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.preferredWidth: 90
|
||||
elide: Text.ElideRight
|
||||
text: modelField.text.length ? agent.modelInfo(modelField.text) : ""
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
|
||||
/* A router can list hundreds of models: filter, cap, say how
|
||||
* many are left instead of filling the dialog. */
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: dlg.choices.length > 12
|
||||
spacing: Theme.gap
|
||||
TextField {
|
||||
id: filterField
|
||||
objectName: "settingsFilter"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.modelFilter
|
||||
implicitHeight: Theme.segmentHeight
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "filter " + dlg.choices.length + " models"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText
|
||||
onTextChanged: dlg.modelFilter = text
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: filterField.activeFocus ? Theme.accentDim
|
||||
: Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.preferredWidth: 110
|
||||
elide: Text.ElideRight
|
||||
text: dlg.chips.length + " shown"
|
||||
+ (dlg.chipsHidden ? " · +" + dlg.chipsHidden + " more" : "")
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
Flow {
|
||||
Layout.fillWidth: true
|
||||
spacing: 6
|
||||
visible: dlg.choices.length > 0
|
||||
|
||||
Repeater {
|
||||
model: dlg.chips.slice(0, dlg.chipCap)
|
||||
delegate: Rectangle {
|
||||
id: chip
|
||||
required property var modelData
|
||||
readonly property string name: String(chip.modelData)
|
||||
|
||||
objectName: "modelChoice:" + chip.name
|
||||
implicitWidth: chipText.implicitWidth + 14
|
||||
implicitHeight: 26
|
||||
radius: Theme.radiusSm
|
||||
color: chipHover.hovered ? Theme.surfaceHi : "transparent"
|
||||
border.width: 1
|
||||
border.color: modelField.text === chip.name ? Theme.accent
|
||||
: Theme.border
|
||||
HoverHandler { id: chipHover }
|
||||
Text {
|
||||
id: chipText
|
||||
anchors.centerIn: parent
|
||||
text: chip.name
|
||||
color: modelField.text === chip.name ? Theme.text
|
||||
: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
TapHandler { onTapped: modelField.text = chip.name }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextField {
|
||||
id: keyField
|
||||
objectName: "settingsKey"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.touchTarget
|
||||
echoMode: TextInput.Password
|
||||
selectByMouse: true
|
||||
color: Theme.text
|
||||
placeholderText: "API key (leave empty to keep what is there)"
|
||||
placeholderTextColor: Theme.textFaint
|
||||
selectionColor: Theme.accentDim
|
||||
inputMethodHints: Qt.ImhNoPredictiveText | Qt.ImhSensitiveData
|
||||
onAccepted: dlg.apply()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: Theme.bg
|
||||
border.width: 1
|
||||
border.color: keyField.activeFocus ? Theme.accentDim : Theme.border
|
||||
}
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: dlg.keyState()
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
|
||||
/* --- advanced ------------------------------------------- */
|
||||
ToolButton {
|
||||
id: advToggle
|
||||
objectName: "settingsAdvanced"
|
||||
Layout.fillWidth: true
|
||||
implicitHeight: Theme.segmentHeight
|
||||
onClicked: dlg.advanced = !dlg.advanced
|
||||
contentItem: RowLayout {
|
||||
spacing: 6
|
||||
ThemeIcon {
|
||||
name: dlg.advanced ? "arrow-down" : "arrow-right"
|
||||
glyph: dlg.advanced ? "▾" : "▸"
|
||||
size: 14
|
||||
color: Theme.textDim
|
||||
}
|
||||
Text {
|
||||
text: "Advanced options"
|
||||
color: advToggle.hovered ? Theme.text : Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
Text {
|
||||
text: "custom provider · model parameters"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: advToggle.hovered ? Theme.surfaceAlt : "transparent"
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
visible: dlg.advanced
|
||||
spacing: 6
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
text: "Provider entry — saved in <config>/cst-agent/models.json. " +
|
||||
"A name the presets know is overridden, a new one is added."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
GridLayout {
|
||||
Layout.fillWidth: true
|
||||
columns: 2
|
||||
columnSpacing: Theme.gap
|
||||
rowSpacing: 4
|
||||
|
||||
AdvField {
|
||||
id: advNameField
|
||||
objectName: "advName"
|
||||
label: "Name"
|
||||
placeholder: "myllm"
|
||||
Layout.preferredWidth: 180
|
||||
}
|
||||
AdvField {
|
||||
id: advBaseField
|
||||
objectName: "advBase"
|
||||
label: "Base URL"
|
||||
placeholder: "https://api.example.com/v1"
|
||||
}
|
||||
AdvField {
|
||||
id: advKeyEnvField
|
||||
objectName: "advKeyEnv"
|
||||
label: "Key variable"
|
||||
placeholder: "MYLLM_API_KEY"
|
||||
}
|
||||
AdvField {
|
||||
id: advModelField
|
||||
objectName: "advModel"
|
||||
label: "Default model"
|
||||
placeholder: "the model to start with"
|
||||
}
|
||||
AdvField {
|
||||
id: advModelsField
|
||||
objectName: "advModels"
|
||||
label: "Models it offers (comma separated, optional)"
|
||||
placeholder: "m1, m2"
|
||||
Layout.columnSpan: 2
|
||||
}
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Button {
|
||||
id: saveProviderBtn
|
||||
objectName: "settingsSaveProvider"
|
||||
text: "Save provider"
|
||||
implicitHeight: Theme.touchTarget
|
||||
enabled: advNameField.text.trim().length > 0 &&
|
||||
advBaseField.text.trim().length > 0
|
||||
onClicked: dlg.saveProvider()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: saveProviderBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: saveProviderBtn.text
|
||||
color: saveProviderBtn.enabled ? Theme.textDim : Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: removeProviderBtn
|
||||
objectName: "settingsRemoveProvider"
|
||||
text: "Remove from models.json"
|
||||
implicitHeight: Theme.touchTarget
|
||||
readonly property var entry: dlg.pickedEntry()
|
||||
enabled: entry !== null && entry.custom === true
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Only the user's own entry goes: a built-in preset of the same name stays"
|
||||
onClicked: dlg.removeProvider()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: removeProviderBtn.hovered && removeProviderBtn.enabled
|
||||
? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: removeProviderBtn.text
|
||||
color: removeProviderBtn.enabled ? Theme.textDim
|
||||
: Theme.textFaint
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
Layout.topMargin: 4
|
||||
text: "Model parameters — remembered for " +
|
||||
(dlg.picked.length ? dlg.picked : "this provider") +
|
||||
", sent with every request. Empty = the provider's own default."
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
AdvField {
|
||||
id: tempField
|
||||
objectName: "advTemperature"
|
||||
label: "Temperature"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 130
|
||||
}
|
||||
AdvField {
|
||||
id: topPField
|
||||
objectName: "advTopP"
|
||||
label: "Top-p"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 130
|
||||
}
|
||||
AdvField {
|
||||
id: maxTokField
|
||||
objectName: "advMaxTokens"
|
||||
label: "Max tokens"
|
||||
placeholder: "not sent"
|
||||
Layout.preferredWidth: 150
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
objectName: "settingsNote"
|
||||
Layout.fillWidth: true
|
||||
text: dlg.note.length ? dlg.note : dlg.usageLine()
|
||||
color: dlg.note.length ? Theme.warn : Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
maximumLineCount: 2
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
Button {
|
||||
id: closeBtn
|
||||
objectName: "settingsClose"
|
||||
text: "Close"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: dlg.close()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: closeBtn.hovered ? Theme.surfaceHi : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
contentItem: Text {
|
||||
text: closeBtn.text
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.bodySize
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
Button {
|
||||
id: applyBtn
|
||||
objectName: "settingsApply"
|
||||
text: "Use this"
|
||||
implicitHeight: Theme.touchTarget
|
||||
onClicked: dlg.apply()
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: applyBtn.hovered ? "#5cc0f0" : Theme.accent
|
||||
}
|
||||
contentItem: Text {
|
||||
text: applyBtn.text
|
||||
color: Theme.accentInk
|
||||
font.pixelSize: Theme.bodySize
|
||||
font.bold: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* The model parameters belong to the provider that is picked, so they are
|
||||
* applied first: useProvider() then takes over what was remembered for
|
||||
* whichever provider it ends on. */
|
||||
function apply() {
|
||||
dlg.applyRequested(dlg.picked, modelField.text, keyField.text,
|
||||
dlg.num(tempField.text), dlg.num(topPField.text),
|
||||
Math.round(dlg.num(maxTokField.text)))
|
||||
}
|
||||
|
||||
onOpened: {
|
||||
keyField.text = ""
|
||||
modelField.forceActiveFocus()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,345 @@
|
||||
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: ""
|
||||
property var sessions: [] // what the store holds (AgentCore::sessions)
|
||||
property string sessionId: ""
|
||||
|
||||
signal modeRequested(string mode)
|
||||
signal approvalRequested(int approval)
|
||||
signal planModeRequested(bool on)
|
||||
signal sessionRequested(string id)
|
||||
signal sessionNewRequested()
|
||||
signal sessionDeleteRequested(string id)
|
||||
|
||||
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.sessionId.length > 0
|
||||
text: "session " + side.sessionId
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideMiddle
|
||||
}
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.status.length > 0
|
||||
text: side.status
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
elide: Text.ElideRight
|
||||
}
|
||||
}
|
||||
|
||||
// --- which conversation ---
|
||||
ColumnLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: 4
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.gap
|
||||
Text {
|
||||
text: "Sessions"
|
||||
color: Theme.textDim
|
||||
font.pixelSize: Theme.smallSize
|
||||
font.bold: true
|
||||
}
|
||||
Item { Layout.fillWidth: true }
|
||||
// Starting a session while a turn runs would move the
|
||||
// engine out from under it: the whole block is off then.
|
||||
ToolButton {
|
||||
id: newSession
|
||||
objectName: "newSessionButton"
|
||||
implicitWidth: 68
|
||||
implicitHeight: Theme.segmentHeight
|
||||
enabled: !side.running
|
||||
ToolTip.visible: hovered
|
||||
ToolTip.text: "Start a new session"
|
||||
contentItem: RowLayout {
|
||||
spacing: 4
|
||||
ThemeIcon {
|
||||
name: "list-add"
|
||||
glyph: "+"
|
||||
size: 14
|
||||
color: newSession.enabled
|
||||
? (newSession.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
}
|
||||
Text {
|
||||
text: "new"
|
||||
color: newSession.enabled
|
||||
? (newSession.hovered ? Theme.text : Theme.textDim)
|
||||
: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
}
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered && parent.enabled ? Theme.surfaceHi
|
||||
: "transparent"
|
||||
border.width: 1
|
||||
border.color: Theme.border
|
||||
}
|
||||
onClicked: side.sessionNewRequested()
|
||||
}
|
||||
}
|
||||
|
||||
Repeater {
|
||||
model: side.sessions
|
||||
delegate: SessionRow {
|
||||
Layout.fillWidth: true
|
||||
entry: modelData
|
||||
onActivated: function (id) { side.sessionRequested(id) }
|
||||
onRemoveRequested: function (id) { side.sessionDeleteRequested(id) }
|
||||
}
|
||||
}
|
||||
|
||||
Text {
|
||||
Layout.fillWidth: true
|
||||
visible: side.sessions.length === 0
|
||||
text: "nothing stored here yet"
|
||||
color: Theme.textFaint
|
||||
font.pixelSize: Theme.smallSize
|
||||
wrapMode: Text.Wrap
|
||||
}
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
/* The Basic style's indicator is a dark pill with a dark
|
||||
* knob here, so it is drawn: thumb-sized, accent when
|
||||
* on, clearly off when not. */
|
||||
indicator: Rectangle {
|
||||
implicitWidth: 44
|
||||
implicitHeight: 22
|
||||
x: planSwitch.leftPadding
|
||||
y: parent.height / 2 - height / 2
|
||||
radius: 11
|
||||
color: planSwitch.checked ? Theme.accentDim : Theme.surfaceAlt
|
||||
border.width: 1
|
||||
border.color: planSwitch.checked ? Theme.accent : Theme.border
|
||||
Rectangle {
|
||||
width: 16
|
||||
height: 16
|
||||
y: 2
|
||||
x: planSwitch.checked ? parent.width - width - 3 : 3
|
||||
radius: 8
|
||||
color: planSwitch.checked ? Theme.text : Theme.textDim
|
||||
Behavior on x {
|
||||
NumberAnimation { duration: 120; easing.type: Easing.OutCubic }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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,94 @@
|
||||
import QtQuick
|
||||
import QtQuick.Controls
|
||||
import QtQuick.Layouts
|
||||
|
||||
Dialog {
|
||||
id: root
|
||||
objectName: "taskControlDialog"
|
||||
title: "Task control and jobs"
|
||||
modal: true
|
||||
standardButtons: Dialog.Close
|
||||
width: Math.min(760, parent ? parent.width - 24 : 760)
|
||||
height: Math.min(640, parent ? parent.height - 24 : 640)
|
||||
property var state: ({})
|
||||
function refresh() {
|
||||
try { state = JSON.parse(agent.taskState()) } catch (e) { state = ({}) }
|
||||
}
|
||||
onOpened: { refresh(); config.text = agent.workerConfig() }
|
||||
Timer { interval: 500; repeat: true; running: root.visible; onTriggered: root.refresh() }
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
TabBar {
|
||||
id: tabs
|
||||
Layout.fillWidth: true
|
||||
TabButton { text: "Task / Jobs" }
|
||||
TabButton { text: "Models" }
|
||||
TabButton { text: "Integration" }
|
||||
}
|
||||
StackLayout {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
currentIndex: tabs.currentIndex
|
||||
ColumnLayout {
|
||||
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (root.state.goal || "No active goal") }
|
||||
Label { text: "Revision " + (root.state.revision || 0) + " · " + (root.state.status || "idle") + " · " + (root.state.cancel_source || "") }
|
||||
RowLayout {
|
||||
CheckBox {
|
||||
id: readonly
|
||||
objectName: "taskReadOnly"
|
||||
text: "Read only"
|
||||
checked: !!root.state.read_only
|
||||
onClicked: agent.taskControl(checked, !direct.checked, !!root.state.paused)
|
||||
}
|
||||
CheckBox {
|
||||
id: direct
|
||||
objectName: "taskMainOnly"
|
||||
text: "Main agent only"
|
||||
checked: root.state.delegation === false
|
||||
onClicked: agent.taskControl(readonly.checked, !checked, !!root.state.paused)
|
||||
}
|
||||
Button { text: root.state.paused ? "Resume" : "Pause"; onClicked: agent.taskControl(readonly.checked, !direct.checked, !root.state.paused) }
|
||||
}
|
||||
ScrollView {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
ColumnLayout {
|
||||
width: parent.width
|
||||
Repeater {
|
||||
model: root.state.jobs || []
|
||||
Frame {
|
||||
required property var modelData
|
||||
Layout.fillWidth: true
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
Label { text: modelData.job_id + " · " + modelData.kind + " · " + modelData.status }
|
||||
Label { Layout.fillWidth: true; text: modelData.resource || ""; elide: Text.ElideMiddle }
|
||||
Label { Layout.fillWidth: true; wrapMode: Text.Wrap; text: (modelData.output || "").slice(-1000) }
|
||||
RowLayout {
|
||||
Button { text: "Cancel"; enabled: modelData.status === "running" || modelData.status === "queued"; onClicked: agent.cancelJob(modelData.job_id, false) }
|
||||
Button { text: "Force stop"; visible: modelData.status === "running_protected"; onClicked: agent.cancelJob(modelData.job_id, true) }
|
||||
Button { text: "Review delta"; visible: (modelData.resource || "").indexOf("/.agent/v2/wt-") >= 0; onClicked: { worktree.text = modelData.resource; tabs.currentIndex = 2 } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ColumnLayout {
|
||||
Label { text: "Role model profiles and budgets (provider credentials stay separate)"; wrapMode: Text.Wrap; Layout.fillWidth: true }
|
||||
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: config; wrapMode: TextEdit.Wrap; selectByMouse: true } }
|
||||
Label { id: configStatus; Layout.fillWidth: true; wrapMode: Text.Wrap }
|
||||
Button { text: "Save model configuration"; enabled: !agent.running; onClicked: { const e = agent.saveWorkerConfig(config.text); configStatus.text = e.length ? e : "Saved" } }
|
||||
}
|
||||
ColumnLayout {
|
||||
TextField { id: worktree; Layout.fillWidth: true; placeholderText: "Worker worktree path" }
|
||||
RowLayout {
|
||||
Button { text: "Preview"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, false) }
|
||||
Button { text: "Integrate reviewed delta"; enabled: !agent.running; onClicked: delta.text = agent.worktreeDiff(worktree.text, true) }
|
||||
}
|
||||
ScrollView { Layout.fillWidth: true; Layout.fillHeight: true; TextArea { id: delta; readOnly: true; wrapMode: TextEdit.Wrap; selectByMouse: true } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
// 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"
|
||||
/* Faint is still text: small print (hints, secondary lines, a selected
|
||||
* row's detail) has to stay readable on both the background and the
|
||||
* accent fill, so it is a step lighter than it looks in a mockup. */
|
||||
readonly property color textFaint: "#8d959c"
|
||||
readonly property color accent: "#3daee9"
|
||||
readonly property color accentDim: "#1d6f95"
|
||||
/* What is written *on* the accent fill (a selection, the open session).
|
||||
* Named "accentText...", not "onAccent...": QML reads any `onFoo` as a
|
||||
* signal handler and refuses the assignment. */
|
||||
readonly property color accentText: "#ffffff"
|
||||
readonly property color accentTextDim: "#dceefb"
|
||||
/* The dark ink a filled button carries (accent, ok, err are all light). */
|
||||
readonly property color accentInk: "#12161a"
|
||||
readonly property color ok: "#27ae60"
|
||||
readonly property color warn: "#f67400"
|
||||
readonly property color err: "#da4453"
|
||||
/* Green on the accent fill is mud: the "in use" mark needs its own ink. */
|
||||
readonly property color okOnAccent: "#d8f5e3"
|
||||
|
||||
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
|
||||
|
||||
// Which Breeze icon stands for a tool: the card shows at a glance what
|
||||
// kind of thing ran. Unknown names still get one (no glyph fallbacks for
|
||||
// tools -- a missing icon just leaves the name alone).
|
||||
function toolIcon(name) {
|
||||
const n = String(name).toLowerCase()
|
||||
if (n.indexOf("patch") >= 0 || n.indexOf("write") >= 0 || n.indexOf("edit") >= 0)
|
||||
return "document-edit"
|
||||
if (n.indexOf("read") >= 0 || n.indexOf("cat") >= 0)
|
||||
return "text-x-generic"
|
||||
if (n.indexOf("list_dir") >= 0 || n.indexOf("glob") >= 0 ||
|
||||
n.indexOf("find") >= 0 || n.indexOf("dir") >= 0 || n.indexOf("file") >= 0)
|
||||
return "folder-open"
|
||||
if (n.indexOf("grep") >= 0 || n.indexOf("search") >= 0)
|
||||
return "edit-find"
|
||||
if (n.indexOf("bash") >= 0 || n.indexOf("shell") >= 0 ||
|
||||
n.indexOf("command") >= 0 || n.indexOf("run") >= 0)
|
||||
return "code-context"
|
||||
if (n.indexOf("web") >= 0 || n.indexOf("http") >= 0 || n.indexOf("fetch") >= 0)
|
||||
return "internet-services"
|
||||
if (n.indexOf("todo") >= 0 || n.indexOf("plan") >= 0)
|
||||
return "format-list-unordered"
|
||||
if (n.indexOf("ask") >= 0 || n.indexOf("question") >= 0 || n.indexOf("notif") >= 0)
|
||||
return "help-hint"
|
||||
return "code-context"
|
||||
}
|
||||
|
||||
// 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,50 @@
|
||||
import QtQuick
|
||||
|
||||
// One icon, from the desktop's icon theme (image://csttheme/, see
|
||||
// app/src/ui/ThemeIcons.cpp), tinted with a color of the window's own.
|
||||
//
|
||||
// `glyph` is the last resort: a machine with no icon theme at all gets the
|
||||
// unicode character the app used before, rather than an empty hole. Every
|
||||
// place that shows an icon names its glyph, so nothing can go blank.
|
||||
Item {
|
||||
id: root
|
||||
property string name: ""
|
||||
property string glyph: ""
|
||||
property color color: Theme.textDim
|
||||
property int size: 16
|
||||
implicitWidth: size
|
||||
implicitHeight: size
|
||||
|
||||
/* "#rrggbb": a color typed to a string in QML carries alpha and a
|
||||
* leading '#', which the provider's query would choke on. */
|
||||
function hex(c) {
|
||||
function two(v) {
|
||||
const s = Math.round(Math.max(0, Math.min(1, v)) * 255).toString(16)
|
||||
return s.length < 2 ? "0" + s : s
|
||||
}
|
||||
return "#" + two(c.r) + two(c.g) + two(c.b)
|
||||
}
|
||||
|
||||
Image {
|
||||
id: img
|
||||
anchors.fill: parent
|
||||
source: root.name.length
|
||||
? "image://csttheme/" + root.name + "?color=" + encodeURIComponent(root.hex(root.color))
|
||||
: ""
|
||||
sourceSize.width: root.size
|
||||
sourceSize.height: root.size
|
||||
fillMode: Image.PreserveAspectFit
|
||||
smooth: true
|
||||
visible: status === Image.Ready
|
||||
}
|
||||
|
||||
Text {
|
||||
anchors.fill: parent
|
||||
visible: img.status === Image.Error && root.glyph.length > 0
|
||||
text: root.glyph
|
||||
color: root.color
|
||||
font.pixelSize: Math.round(root.size * 0.85)
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
verticalAlignment: Text.AlignVCenter
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
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
|
||||
/* A handler, not a MouseArea: a layout decides the geometry of its
|
||||
* children, and anchoring one of them is undefined behavior. */
|
||||
TapHandler {
|
||||
cursorShape: Qt.PointingHandCursor
|
||||
onTapped: block.expanded = !block.expanded
|
||||
}
|
||||
ThemeIcon {
|
||||
name: block.expanded ? "arrow-down" : "arrow-right"
|
||||
glyph: block.expanded ? "▾" : "▸"
|
||||
size: Theme.smallSize + 2
|
||||
color: Theme.textDim
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
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,61 @@
|
||||
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
|
||||
ThemeIcon {
|
||||
Layout.alignment: Qt.AlignTop
|
||||
name: modelData.done ? "task-complete" : "box"
|
||||
glyph: modelData.done ? "✓" : "○"
|
||||
size: Theme.smallSize + 2
|
||||
color: modelData.done ? Theme.ok : Theme.textFaint
|
||||
}
|
||||
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,225 @@
|
||||
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
|
||||
// | unfinished (replayed, no result)
|
||||
property string detail: ""
|
||||
property string output: ""
|
||||
property int exitCode: 0
|
||||
property bool hasResult: false
|
||||
property bool expanded: false
|
||||
property string images: ""
|
||||
|
||||
readonly property bool running: state === "pending" || state === "ready" || state === "running"
|
||||
readonly property color stateColor: state === "denied" ? Theme.err
|
||||
: state === "unfinished" ? Theme.textFaint
|
||||
: 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"
|
||||
: state === "unfinished" ? "no result"
|
||||
: hasResult && exitCode !== 0 ? "failed" : "done"
|
||||
|
||||
/* How long this call has been going. A tool that takes minutes -- a
|
||||
* build, a wide search, a command nobody can predict -- must not read as
|
||||
* a window that has hung: while the card runs, its chip counts. */
|
||||
property double startedAt: 0
|
||||
property int runningFor: 0
|
||||
|
||||
function elapsedText() {
|
||||
const s = runningFor
|
||||
if (s < 60)
|
||||
return s + "s"
|
||||
const m = Math.floor(s / 60)
|
||||
if (m < 60)
|
||||
return m + ":" + (s % 60 < 10 ? "0" : "") + (s % 60)
|
||||
return Math.floor(m / 60) + "h" + (m % 60 < 10 ? "0" : "") + (m % 60)
|
||||
}
|
||||
|
||||
/* The clock starts when the card starts running -- and a card that is
|
||||
* already running when it appears (a replayed transcript) starts one too,
|
||||
* hence Component.onCompleted. */
|
||||
function noteRunning() {
|
||||
if (running && startedAt === 0)
|
||||
startedAt = Date.now()
|
||||
else if (!running)
|
||||
startedAt = 0
|
||||
}
|
||||
|
||||
onRunningChanged: noteRunning()
|
||||
Component.onCompleted: noteRunning()
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
repeat: true
|
||||
running: card.running && card.startedAt > 0
|
||||
onTriggered: card.runningFor = Math.floor((Date.now() - card.startedAt) / 1000)
|
||||
}
|
||||
|
||||
/* What the chip says: the state, how long it has been running (from five
|
||||
* seconds on -- before that it is noise), or the exit code once there is
|
||||
* a result. "no result" is not an outcome, so no exit code belongs on
|
||||
* it. */
|
||||
function chipText() {
|
||||
if (running)
|
||||
return runningFor >= 5
|
||||
? stateText + " · " + elapsedText()
|
||||
: stateText
|
||||
return stateText + (hasResult && state !== "unfinished"
|
||||
? " · exit " + exitCode : "")
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
/* What kind of tool this was, from the icon theme (Theme.toolIcon
|
||||
* maps the engine's names onto Breeze ones). */
|
||||
ThemeIcon {
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
name: Theme.toolIcon(card.name)
|
||||
size: 16
|
||||
color: Theme.textDim
|
||||
}
|
||||
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
|
||||
objectName: "toolChip"
|
||||
anchors.centerIn: parent
|
||||
text: card.chipText()
|
||||
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: ThemeIcon {
|
||||
name: card.expanded ? "arrow-down" : "arrow-right"
|
||||
glyph: card.expanded ? "▾" : "▸"
|
||||
size: 14
|
||||
color: parent.hovered ? Theme.text : Theme.textDim
|
||||
}
|
||||
background: Rectangle {
|
||||
radius: Theme.radiusSm
|
||||
color: parent.hovered ? Theme.surfaceHi : "transparent"
|
||||
}
|
||||
onClicked: card.expanded = !card.expanded
|
||||
}
|
||||
}
|
||||
|
||||
// What the tool made (view_image, an MCP image): shown as it is, with
|
||||
// the output left folded -- the picture is the result.
|
||||
ImageStrip {
|
||||
Layout.fillWidth: true
|
||||
visible: card.images.length > 0
|
||||
text: card.images
|
||||
}
|
||||
|
||||
// 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