The window drew its icons with unicode glyphs -- a clip emoji for attach, a
gear, a hamburger, a chevron, a multiplication sign for delete. Those render
in whichever font happens to cover them (colour emoji in a grey toolbar) and
are a box on a machine without that glyph. They now come from the icon theme,
the way every other desktop app gets them.
Icons
- app/src/ui/ThemeIcons.{h,cpp}: a QQuickImageProvider for
image://csttheme/<name>?color=%23rrggbb -- QIcon::fromTheme() in the
desktop's theme (whatever the platform theme set, else breeze-dark, breeze,
hicolor), tinted with QPainter's SourceIn and rasterised at the requested
size and the screen's device pixel ratio. Qt only builds its theme search
paths from XDG once a theme name is set, so the provider fills them itself
(GenericDataLocation/icons, ~/.icons and, on NixOS,
/run/current-system/sw/share/icons, for a shell started outside the desktop
session). An unknown name yields a null image, which QML reports as
Image.Error.
- app/qml/ThemeIcon.qml: the one component that asks for an icon. Every use
still names the old character as `glyph:`, so a host with no icon theme
degrades to exactly what the window looked like before instead of going
blank. installThemeIcons() is called by main() and by --uitest, so the test
loads the same icons the app does.
- Replaced: nav (application-menu), settings (settings-configure), attach
(mail-attachment), copy (edit-copy), session delete (edit-delete), new
session (list-add), expand/collapse (arrow-right/arrow-down), jump to the end
(go-down), the plan checklist (task-complete/box). A tool card also shows
what kind of tool ran -- Theme.toolIcon(): pencil for patch/write, text page
for read, folder for list/glob, magnifier for grep, braces for a shell,
globe for web, list for a plan. Toolbar icons are 22 px (Breeze's toolbar
size), card and row icons 14-16 px.
Colours (the "some text is unreadable" part)
- textFaint #6d757b -> #8d959c: ~3.4:1 -> ~5:1 on the card fill. It carries
hints, provider URLs, session metadata and placeholders, so it is text, not
decoration.
- A selected provider row and the open session row write on the accent fill;
they now use accentText / accentTextDim / okOnAccent instead of faint grey
and green on blue (named "accentText", not "onAccent": QML reads any `onFoo`
property as a signal handler and refuses the assignment).
- The filled buttons (Send, Use this) take their ink from Theme.accentInk.
Composer
- The frame is one Theme.pad around its column on every side (anchors.fill,
implicitHeight + 2*pad). 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.
app/README.md gains an "Icons, colors" section (the provider, why the theme
search paths are filled by hand, the qtsvg requirement for rasterising Breeze,
the tool map, and the contrast rules).
Verified: --uitest 52/52, --selftest OK, and the 0.8.14 window relaunched in
the Plasma session (no QML warnings).
Version 0.8.14.
mode = coding
346 lines
14 KiB
QML
346 lines
14 KiB
QML
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
|
|
}
|
|
}
|
|
}
|
|
}
|