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
151 lines
5.1 KiB
QML
151 lines
5.1 KiB
QML
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
|
|
/* 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
|
|
}
|
|
}
|
|
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()
|
|
}
|
|
}
|
|
}
|