v0.8.8: Qt front end redesign, responsive shell, UI input tests

The window is no longer one file pretending to be a chat box. The transcript,
the run panel, the composer and the two consent dialogs are their own QML
components over one Theme singleton, and the shell around them adapts to the
screen: docked and scrollable on a desktop, a slide-out drawer on a phone.

QML (app/qml/, new except Main.qml):
- Theme.qml: single QML singleton, dark Breeze-flavoured tokens (surface,
  border, accent, text/textDim/textFaint, radius, pad, gap, sizes, monospace);
  diffLineColor() for unified-diff rows, compactBreakpoint 880, touchTarget
  34, segmentHeight 30, headerHeight 42.
- ChatView.qml: a row-type dispatcher over the transcript model (message,
  thought, tool, note), follow-the-tail scrolling with a "jump to latest"
  button once the user scrolls up, and an empty state that says where the
  agent runs and what it can do.
- MessageCard.qml, ThinkBlock.qml, NoteLine.qml, ToolCard.qml (status pill,
  arguments, collapsible output, 500-line cut, diff colouring, spinner while
  the call runs), TodoPanel.qml.
- Sidebar.qml: workspace/provider/status, mode and approval segmented rows,
  plan switch, the engine's todo list, version footer; scrolls, so a short
  window still reaches every control.
- Composer.qml: auto-growing field, Enter sends / Shift+Enter newlines,
  red Stop while a turn runs, autocorrect hints off, finger-sized targets.
- ConsentDialog.qml (Deny / Once / Always -> CST_PERM_*) and AskDialog.qml
  (the model's options become buttons, free text stays possible).
- Main.qml: shell with header, draggable sidebar splitter (hover feedback,
  200-560 px, double-click resets), transcript, composer, the two dialogs,
  Esc to cancel and Ctrl+L to clear. Under 880 px the sidebar becomes a
  drawer with a scrim instead of squeezing the chat away.

AgentCore / main.cpp:
- provider, model, approval and planMode are properties now; approval and
  plan mode write back into the engine's config, and renderMarkdown() calls
  cst_web_md_html(), so a table or a code fence looks like the WebUI's.
- the todo hook writes m_todo on the GUI thread (it was written from the
  worker thread before: a data race on a QString member).
- --size WxH for the window (the layout tests drive it), and the Basic style
  is chosen by default: the Breeze Quick style registers its types with
  relative URLs, which floods the log and breaks the composer.

UI tests (app/src/ui/):
- --uitest drives the real window with synthesized input and exits with the
  number of failures: 15 checks over docking/drawer transitions, mouse clicks
  and touch taps reaching the engine's mode, the plan switch, the composer
  arming the send button, and a 460x900 -> 1200x820 resize round trip.
- every control the tests touch carries an objectName; the sidebar's
  implicitHeight binding loop is gone.

Tests: cst-app-selftest and --uitest pass in the standalone app build,
ctest cst-app-selftest passes in the repo build (38/38 on the host).

Version 0.8.8.
This commit is contained in:
WHC
2026-10-11 11:58:23 +08:00
parent 5ba215b36c
commit da812cc7e8
20 changed files with 1874 additions and 179 deletions
+108
View File
@@ -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()
}
+134
View File
@@ -0,0 +1,134 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The transcript: cards for the messages, a card per tool call, folded
// thinking blocks and one-line notes. Follows the tail of the conversation
// until the user scrolls away from it.
Item {
id: view
property var model: null
property bool running: false
ListView {
id: list
anchors.fill: parent
anchors.leftMargin: Theme.gapL
anchors.rightMargin: Theme.gapL
model: view.model
clip: true
spacing: Theme.gap
topMargin: Theme.gapL
bottomMargin: Theme.gapL
cacheBuffer: 2000
boundsBehavior: Flickable.StopAtBounds
// The delegate picks the card by row kind; the components below read
// the ListModel roles directly, which is why they are declared in the
// delegate's scope.
delegate: Loader {
id: rowLoader
width: list.width
sourceComponent: model.kind === "tool" ? toolComp
: model.kind === "think" ? thinkComp
: model.kind === "note" ? noteComp : msgComp
Component {
id: msgComp
MessageCard {
role: model.role
text: model.text
complete: model.complete
html: (model.role === "agent" || model.role === "error") &&
(model.complete || model.text.length > 12000) &&
model.text.length > 0
? agent.renderMarkdown(model.text) : ""
}
}
Component {
id: thinkComp
ThinkBlock {
text: model.text
complete: model.complete
}
}
Component {
id: toolComp
ToolCard {
name: model.name
state: model.state
detail: model.detail
output: model.output
exitCode: model.exitCode
hasResult: model.complete
}
}
Component {
id: noteComp
NoteLine {
text: model.text
level: model.state === "warn" || model.state === "error" ? model.state : "info"
}
}
}
onMovementEnded: view.stickBottom = atYEnd
onCountChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
onContentHeightChanged: if (view.stickBottom) Qt.callLater(list.positionViewAtEnd)
}
// Follow the tail unless the user asked otherwise.
property bool stickBottom: true
// Empty state: what this window is and what to do with it.
ColumnLayout {
anchors.centerIn: parent
width: Math.min(parent.width - 80, 520)
spacing: Theme.gap
visible: view.model ? view.model.count === 0 : true
Text {
Layout.alignment: Qt.AlignHCenter
text: "CST-agent"
color: Theme.text
font.pixelSize: 22
font.bold: true
}
Text {
Layout.alignment: Qt.AlignHCenter
text: "The engine runs in this window — no server, no second process."
color: Theme.textDim
font.pixelSize: Theme.bodySize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
Text {
Layout.alignment: Qt.AlignHCenter
Layout.topMargin: Theme.gap
text: "Ask for a change, a command to run, a file to explain.\n" +
"Every write, delete and command asks first: Deny / Once / Always.\n" +
"Enter sends, Shift+Enter adds a line, Esc stops a running turn."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.Wrap
Layout.fillWidth: true
}
}
// Jump back to the live end of the conversation.
RoundButton {
anchors.right: parent.right
anchors.bottom: parent.bottom
anchors.margins: Theme.padL
width: Theme.jumpButton
height: Theme.jumpButton
visible: !view.stickBottom && list.contentHeight > list.height
text: "↓"
onClicked: {
view.stickBottom = true
list.positionViewAtEnd()
}
}
}
+113
View File
@@ -0,0 +1,113 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The input line: multiline, Enter sends, the button changes with the turn
// state. Sized for a thumb as much as for a mouse, and it asks the platform
// keyboard not to autocorrect what is usually code or a path.
Rectangle {
id: root
objectName: "composer"
property bool running: false
property bool compact: false
property alias text: input.text
signal submitted(string text)
function submit() {
const t = input.text.trim()
if (!t.length || running)
return
input.text = ""
submitted(t)
}
radius: Theme.radius
color: Theme.surface
border.width: 1
border.color: input.activeFocus ? Theme.accentDim : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: 6
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
TextArea {
id: input
objectName: "composerInput"
Layout.fillWidth: true
Layout.preferredHeight: Math.min(root.compact ? 120 : 170,
Math.max(56, contentHeight + 12))
placeholderText: root.running
? "the turn is running — Esc stops it"
: (root.compact
? "Ask for something…"
: "Ask for something (Enter sends, Shift+Enter for a new line)")
placeholderTextColor: Theme.textFaint
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: TextArea.Wrap
selectByMouse: true
selectionColor: Theme.accentDim
inputMethodHints: Qt.ImhMultiLine | Qt.ImhNoPredictiveText |
Qt.ImhNoAutoUppercase
background: Rectangle { color: "transparent" }
enabled: !root.running
Keys.onPressed: function (ev) {
if (ev.key === Qt.Key_Return || ev.key === Qt.Key_Enter) {
if (!(ev.modifiers & Qt.ShiftModifier)) {
root.submit()
ev.accepted = true
}
}
}
}
Button {
id: action
objectName: "sendButton"
Layout.alignment: Qt.AlignBottom
implicitWidth: root.compact ? 68 : 88
implicitHeight: Theme.touchTarget + 4
text: root.running ? "Stop" : "Send"
enabled: root.running || input.text.trim().length > 0
onClicked: root.running ? agent.cancel() : root.submit()
background: Rectangle {
radius: Theme.radiusSm
color: !action.enabled ? Theme.surfaceAlt
: root.running ? (action.hovered ? "#e05666" : Theme.err)
: (action.hovered ? "#5cc0f0" : Theme.accent)
}
contentItem: Text {
text: action.text
color: action.enabled ? "#12161a" : Theme.textFaint
font.pixelSize: Theme.bodySize
font.bold: true
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
// On a narrow window the hint would wrap into the editor's space.
Text {
Layout.fillWidth: true
visible: !root.compact
text: root.running
? "working — Esc cancels the turn"
: "Enter sends · Shift+Enter newline · Esc stops · every change asks first"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
}
+122
View File
@@ -0,0 +1,122 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The engine's on_permission hook is blocked on this dialog: nothing runs
// until one of the three answers goes back.
Dialog {
id: dialog
property string tool: ""
property string detail: ""
property string preview: ""
signal answered(int choice)
modal: true
anchors.centerIn: parent
width: Math.min(760, parent ? parent.width - 60 : 760)
padding: Theme.pad
title: ""
background: Rectangle {
color: Theme.surface
border.width: 1
border.color: Theme.border
radius: Theme.radius
}
contentItem: ColumnLayout {
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle {
width: 8
height: 8
radius: 4
color: Theme.warn
}
Text {
text: "Permission requested"
color: Theme.text
font.pixelSize: Theme.bodySize + 1
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: dialog.tool
color: Theme.warn
font.family: Theme.mono
font.pixelSize: Theme.monoSize
}
}
Text {
Layout.fillWidth: true
visible: dialog.detail.length > 0
text: dialog.detail
color: Theme.textDim
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(320, previewText.implicitHeight + Theme.pad * 2)
visible: dialog.preview.length > 0
color: Theme.bg
radius: Theme.radiusSm
border.width: 1
border.color: Theme.border
Flickable {
id: flick
anchors.fill: parent
anchors.margins: 8
contentWidth: previewText.width
contentHeight: previewText.height
clip: true
ScrollBar.vertical: ScrollBar {}
Text {
id: previewText
width: Math.max(flick.width, implicitWidth)
text: dialog.preview
color: Theme.text
font.family: Theme.mono
font.pixelSize: Theme.monoSize
textFormat: Text.PlainText
}
}
}
Text {
Layout.fillWidth: true
text: "“Always” is remembered for this tool in .agent/permissions.json — the TUI and the CLI see the same decision."
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
footer: DialogButtonBox {
spacing: Theme.gap
padding: Theme.pad
Button {
text: "Deny"
DialogButtonBox.buttonRole: DialogButtonBox.RejectRole
onClicked: { dialog.answered(0); dialog.close() }
}
Button {
text: "Once"
highlighted: true
DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole
onClicked: { dialog.answered(1); dialog.close() }
}
Button {
text: "Always"
DialogButtonBox.buttonRole: DialogButtonBox.ActionRole
onClicked: { dialog.answered(2); dialog.close() }
}
}
}
+347 -171
View File
@@ -1,9 +1,11 @@
// The app window: chat on the left, tool runs and consent on top of it.
// The window: a sidebar with the engine's settings, the transcript, the
// composer, and the two dialogs the engine can block on.
//
// Qt Quick Controls only, laid out the way Kirigami expects (single column,
// cards, drawer for navigation) so that swapping `import QtQuick.Controls`
// for `import org.kde.kirigami as Kirigami` and Page/Card wrappers is a QML
// change, not a rewrite.
// One shell, three sizes: the sidebar is docked on a desktop (and can be
// dragged), slides over the transcript when the window is narrow, and every
// control keeps a finger-sized hit area for touch screens. Qt Quick Controls
// only, laid out the way Kirigami expects (sidebar + page, cards, header), so
// swapping in org.kde.kirigami later stays a QML change.
import QtQuick
import QtQuick.Controls
@@ -11,219 +13,393 @@ import QtQuick.Layouts
ApplicationWindow {
id: win
width: 980
height: 720
width: initialWidth > 0 ? initialWidth : 1180
height: initialHeight > 0 ? initialHeight : 800
minimumWidth: 360
minimumHeight: 420
visible: true
color: Theme.bg
title: "CST-agent — " + (agent.workspace.length ? agent.workspace : "no workspace")
property string statusText: ""
property bool navOpen: false // the sliding sidebar, compact only
property int sidebarWidth: 0 // 0 = follow the window width
readonly property bool compact: width < Theme.compactBreakpoint
readonly property int sidebarWide: sidebarWidth > 0
? sidebarWidth
: Math.max(228, Math.min(360, Math.round(width * 0.26)))
// The settings panel, used docked and in the sliding panel: one definition,
// two places.
component ShellSidebar: Sidebar {
workspace: agent.workspace
provider: agent.provider
model: agent.model
mode: agent.mode
approval: agent.approval
planMode: agent.planMode
running: agent.running
status: win.statusText
todo: agent.todo
onModeRequested: function (m) { agent.setMode(m) }
onApprovalRequested: function (a) { agent.setApproval(a) }
onPlanModeRequested: function (on) { agent.setPlanMode(on) }
}
// ---- the transcript --------------------------------------------------
ListModel { id: transcript }
function lastIndex() { return transcript.count - 1 }
function lastRow() { return transcript.count ? transcript.get(lastIndex()) : null }
function addRow(kind, role, state) {
transcript.append({ kind: kind, role: role, text: "", complete: false,
name: "", state: state, detail: "", output: "", exitCode: 0 })
}
// The engine streams one kind at a time: close whatever was open before.
function closeOpenRows(exceptKind) {
const last = lastRow()
if (last && !last.complete && last.kind !== exceptKind)
transcript.setProperty(lastIndex(), "complete", true)
}
function appendDelta(kind, role, text) {
closeOpenRows(kind)
if (!lastRow() || lastRow().kind !== kind || lastRow().complete)
addRow(kind, role, "running")
transcript.setProperty(lastIndex(), "text", lastRow().text + text)
}
// The last tool row with this name that is still open, -1 when none.
function findTool(name) {
for (let i = transcript.count - 1; i >= 0; i--) {
const r = transcript.get(i)
if (r.kind !== "tool")
continue
if (!r.complete && r.name === name)
return i
if (r.complete)
return -1
}
return -1
}
function openTool(name, state) {
closeOpenRows("tool")
addRow("tool", "tool", state)
transcript.setProperty(lastIndex(), "name", name)
return lastIndex()
}
function submit(text) {
transcript.append({ kind: "message", role: "user", text: text, complete: true,
name: "", state: "", detail: "", output: "", exitCode: 0 })
agent.send(text)
}
// ---- the shell -------------------------------------------------------
header: ToolBar {
id: appBar
implicitHeight: Theme.headerHeight
background: Rectangle {
color: Theme.surface
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: 1
color: Theme.border
}
}
RowLayout {
anchors.fill: parent
Label {
text: agent.workspace.length ? agent.workspace : "(no workspace)"
elide: Text.ElideMiddle
anchors.leftMargin: Theme.pad
anchors.rightMargin: Theme.gap
spacing: Theme.gap
// The docked sidebar has its own title: only the sliding one needs
// a way in.
ToolButton {
objectName: "navButton"
visible: win.compact
implicitWidth: Theme.touchTarget
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered && !win.compact
ToolTip.text: "Settings and plan"
contentItem: Text {
text: "☰"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: 17
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: win.navOpen = !win.navOpen
}
Text {
visible: !win.compact
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideMiddle
text: agent.workspace.length ? agent.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
objectName: "statusText"
visible: win.statusText.length > 0 && !win.compact
text: win.statusText
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
Layout.maximumWidth: 260
}
BusyIndicator {
running: agent.running
visible: agent.running
implicitWidth: 24
implicitHeight: 24
implicitWidth: 22
implicitHeight: 22
}
ComboBox {
id: modeBox
model: ["coding", "general"]
currentIndex: agent.mode === "general" ? 1 : 0
onActivated: agent.setMode(model[currentIndex])
}
}
}
// ---- consent: the engine's on_permission hook waits for this ----
Dialog {
id: consent
anchors.centerIn: parent
width: Math.min(720, win.width - 40)
modal: true
property string tool: ""
property string detail: ""
property string preview: ""
title: "Allow " + tool + "?"
contentItem: ColumnLayout {
Label { text: consent.detail; wrapMode: Text.Wrap; Layout.fillWidth: true }
ScrollView {
Layout.fillWidth: true
Layout.preferredHeight: Math.min(300, previewText.implicitHeight + 16)
visible: consent.preview.length > 0
TextArea {
id: previewText
text: consent.preview
readOnly: true
font.family: "monospace"
wrapMode: TextArea.NoWrap
ToolButton {
objectName: "clearButton"
enabled: transcript.count > 0 && !agent.running
implicitWidth: win.compact ? Theme.touchTarget : 56
implicitHeight: Theme.touchTarget
ToolTip.visible: hovered
ToolTip.text: "Clear the transcript (Ctrl+L) — the session file keeps its history"
contentItem: Text {
text: "Clear"
color: parent.enabled ? (parent.hovered ? Theme.text : Theme.textDim) : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered && parent.enabled ? Theme.surfaceHi : "transparent"
}
onClicked: {
transcript.clear()
win.statusText = ""
}
}
}
footer: DialogButtonBox {
Button { text: "Deny"; DialogButtonBox.buttonRole: DialogButtonBox.RejectRole }
Button { text: "Once"; DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole }
Button { text: "Always"; DialogButtonBox.buttonRole: DialogButtonBox.ActionRole }
onRejected: { agent.answerPermission(0); consent.close() }
onAccepted: { agent.answerPermission(1); consent.close() }
onClicked: function (button) {
if (button.text === "Always") { agent.answerPermission(2); consent.close() }
}
}
}
// ---- ask_user ----
Dialog {
id: question
anchors.centerIn: parent
width: Math.min(600, win.width - 40)
modal: true
property string text: ""
property string options: ""
title: "The agent asks"
contentItem: ColumnLayout {
Label { text: question.text; wrapMode: Text.Wrap; Layout.fillWidth: true }
Label {
visible: question.options.length > 0
text: question.options
wrapMode: Text.Wrap
Layout.fillWidth: true
opacity: 0.7
}
TextField { id: answerField; Layout.fillWidth: true; placeholderText: "your answer" }
}
footer: DialogButtonBox {
Button { text: "Send"; DialogButtonBox.buttonRole: DialogButtonBox.AcceptRole }
onAccepted: { agent.answerQuestion(answerField.text); question.close() }
}
}
ColumnLayout {
RowLayout {
anchors.fill: parent
anchors.margins: 12
spacing: 8
spacing: 0
ListView {
id: chat
ShellSidebar {
id: dockedSidebar
objectName: "dockedSidebar"
visible: !win.compact
Layout.fillHeight: true
Layout.preferredWidth: win.sidebarWide
}
// Drag the divider: a mouse affordance that costs nothing on touch
// (the handle is only there while the sidebar is docked).
Item {
id: divider
visible: !win.compact
Layout.fillHeight: true
implicitWidth: 5
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 1
height: parent.height
color: dividerMa.pressed ? Theme.accentDim
: dividerMa.containsMouse ? Theme.surfaceHi : Theme.border
}
MouseArea {
id: dividerMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.SplitHCursor
property real startX: 0
property int startW: 0
onPressed: function (mouse) {
startX = mouse.x
startW = win.sidebarWide
}
onPositionChanged: function (mouse) {
if (pressed)
win.sidebarWidth = Math.max(200, Math.min(560, startW + Math.round(mouse.x - startX)))
}
onDoubleClicked: win.sidebarWidth = 0
}
}
ColumnLayout {
Layout.fillWidth: true
Layout.fillHeight: true
model: transcript
clip: true
spacing: 8
delegate: Frame {
width: chat.width
background: Rectangle {
color: model.role === "user" ? "#2b579a22" : "#00000000"
border.color: "#88888833"
radius: 6
}
ColumnLayout {
width: parent.width
spacing: 2
Label {
text: model.role === "user" ? "You" : "Agent"
font.bold: true
opacity: 0.7
}
TextEdit {
text: model.text
readOnly: true
wrapMode: TextEdit.Wrap
selectByMouse: true
Layout.fillWidth: true
}
}
}
onCountChanged: positionViewAtEnd()
}
spacing: 0
RowLayout {
Layout.fillWidth: true
TextArea {
id: composer
ChatView {
id: chat
Layout.fillWidth: true
Layout.preferredHeight: 90
placeholderText: agent.running ? "… working (Esc cancels)"
: "Ask something (Enter sends, Shift+Enter for a new line)"
wrapMode: TextArea.Wrap
enabled: !agent.running
Keys.onPressed: function (ev) {
if (ev.key === Qt.Key_Return && !(ev.modifiers & Qt.ShiftModifier)) {
sendButton.clicked()
ev.accepted = true
}
}
Layout.fillHeight: true
model: transcript
running: agent.running
}
ColumnLayout {
Button {
id: sendButton
text: "Send"
enabled: !agent.running && composer.text.trim().length > 0
onClicked: {
transcript.append({ role: "user", text: composer.text })
transcript.append({ role: "agent", text: "" })
agent.send(composer.text)
composer.text = ""
}
}
Button {
text: "Stop"
enabled: agent.running
onClicked: agent.cancel()
}
Composer {
Layout.fillWidth: true
Layout.leftMargin: win.compact ? Theme.gap : Theme.gapL
Layout.rightMargin: win.compact ? Theme.gap : Theme.gapL
Layout.bottomMargin: win.compact ? Theme.gap : Theme.gapL
running: agent.running
compact: win.compact
onSubmitted: function (text) { win.submit(text) }
}
}
}
Shortcut { sequence: "Escape"; onActivated: agent.cancel() }
// ---- engine -> UI ----
function appendToAgent(text) {
if (transcript.count === 0)
transcript.append({ role: "agent", text: "" })
const last = transcript.count - 1
transcript.setProperty(last, "text", transcript.get(last).text + text)
// ---- compact: the sidebar slides in over the transcript --------------
Rectangle {
id: scrim
objectName: "navScrim"
visible: win.navOpen && win.compact
anchors.fill: parent
color: "#000000"
opacity: 0.45
MouseArea {
anchors.fill: parent
onClicked: win.navOpen = false
onWheel: function (wheel) { wheel.accepted = true }
}
}
Rectangle {
id: navPanel
objectName: "navPanel"
visible: win.navOpen && win.compact
anchors {
left: parent.left
top: parent.top
bottom: parent.bottom
}
width: Math.min(320, win.width * 0.88)
color: Theme.surface
border.width: 1
border.color: Theme.border
x: win.navOpen && win.compact ? 0 : -width
Behavior on x { NumberAnimation { duration: 140; easing.type: Easing.OutCubic } }
Flickable {
anchors.fill: parent
contentHeight: panelSidebar.implicitHeight
clip: true
ScrollBar.vertical: ScrollBar {}
ShellSidebar {
id: panelSidebar
width: parent.width
border.width: 0
}
}
}
ConsentDialog {
id: consent
onAnswered: function (choice) { agent.answerPermission(choice) }
}
AskDialog {
id: ask
onAnswered: function (text) { agent.answerQuestion(text) }
}
Shortcut { sequence: "Escape"; enabled: agent.running; onActivated: agent.cancel() }
Shortcut {
sequence: "Ctrl+L"
enabled: !agent.running
onActivated: {
transcript.clear()
win.statusText = ""
}
}
// ---- engine -> UI ----------------------------------------------------
Connections {
target: agent
function onAssistantDelta(text) { appendToAgent(text) }
function onStatusChanged(text) {
transcript.append({ role: "agent", text: "· " + text })
}
function onAssistantDelta(text) { win.appendDelta("message", "agent", text) }
function onThinkDelta(text) { win.appendDelta("think", "agent", text) }
function onStatusChanged(text) { win.statusText = text }
function onToolEvent(phase, name, detail) {
transcript.append({
role: "agent",
text: "[" + name + " " + phase + (detail.length ? ": " + detail : "") + "]"
})
win.closeOpenRows("tool")
let i = win.findTool(name)
if (phase === "open" || phase === "arg") {
if (i < 0)
i = win.openTool(name, "pending")
if (detail.length)
transcript.setProperty(i, "detail", transcript.get(i).detail + detail)
return
}
if (phase === "ready" || phase === "run") {
if (i < 0)
i = win.openTool(name, phase)
transcript.setProperty(i, "state", phase === "run" ? "running" : "queued")
transcript.setProperty(i, "detail", detail)
return
}
// done | denied
if (i < 0)
i = win.openTool(name, phase)
transcript.setProperty(i, "state", phase)
if (detail.length && transcript.get(i).output.length === 0)
transcript.setProperty(i, "output", detail)
}
function onToolResult(name, exitCode, output) {
transcript.append({
role: "agent",
text: "[" + name + " exit=" + exitCode + "]\n" + output
})
let i = win.findTool(name)
if (i < 0)
i = transcript.count - 1
if (i < 0 || transcript.get(i).kind !== "tool")
return
transcript.setProperty(i, "output", output)
transcript.setProperty(i, "exitCode", exitCode)
transcript.setProperty(i, "complete", true)
}
function onPermissionRequested(tool, detail, preview) {
consent.tool = tool
consent.detail = detail
consent.preview = preview
consent.open()
}
function onQuestionAsked(text, options) {
question.text = text
question.options = options
answerField.text = ""
question.open()
function onQuestionAsked(question, options) {
ask.question = question
ask.optionText = options
ask.open()
}
function onTurnFinished(rc, err) {
win.closeOpenRows("")
if (err.length)
transcript.append({ role: "agent", text: "error: " + err })
transcript.append({ kind: "note", role: "error", text: err, complete: true,
name: "", state: "error", detail: "", output: "", exitCode: 0 })
win.statusText = ""
}
function onRunningChanged() {
if (!agent.running)
win.closeOpenRows("")
}
}
}
+109
View File
@@ -0,0 +1,109 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// One message in the transcript: the prompt, the answer, or an error. The
// answer arrives as plain text while it streams and is rendered from markdown
// (by the engine, through AgentCore::renderMarkdown) once it is complete.
Rectangle {
id: card
property string role: "agent" // user | agent | error
property string text: ""
property string html: ""
property bool complete: true
radius: Theme.radius
color: role === "user" ? Theme.surfaceAlt : Theme.surface
border.width: 1
border.color: role === "error" ? Theme.err
: role === "user" ? Theme.border : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad * 2
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: Theme.gap
RowLayout {
spacing: Theme.gap
Rectangle {
width: 6
height: 6
radius: 3
color: card.role === "user" ? Theme.accent
: card.role === "error" ? Theme.err : Theme.ok
}
Text {
text: card.role === "user" ? "You"
: card.role === "error" ? "Error" : "CST-agent"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Text {
visible: !card.complete
text: "streaming…"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
ToolButton {
implicitWidth: 26
implicitHeight: 22
visible: card.text.length > 0
ToolTip.visible: hovered
ToolTip.text: "Copy"
contentItem: Text {
text: "⧉"
color: parent.hovered ? Theme.text : Theme.textFaint
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: {
plain.text = card.text
plain.selectAll()
plain.copy()
}
}
}
// Hidden holder so "copy" always has the plain text, rtf or not.
TextEdit {
id: plain
visible: false
}
TextEdit {
id: streamText
visible: card.html.length === 0
Layout.fillWidth: true
text: card.text
readOnly: true
selectByMouse: true
wrapMode: TextEdit.Wrap
color: card.role === "error" ? Theme.err : Theme.text
font.pixelSize: Theme.bodySize
selectionColor: Theme.accentDim
}
Text {
visible: card.html.length > 0
Layout.fillWidth: true
text: card.html
textFormat: Text.RichText
color: Theme.text
font.pixelSize: Theme.bodySize
wrapMode: Text.Wrap
onLinkActivated: function (link) { Qt.openUrlExternally(link) }
}
}
}
+29
View File
@@ -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
}
}
+234
View File
@@ -0,0 +1,234 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// What the engine is set to: where it runs, how it frames itself, when it
// asks, whether it is planning. Everything here writes straight into the
// engine's config (AgentCore), never into a copy the engine cannot see.
//
// The panel scrolls (a phone-sized window cannot show it all) and every
// control keeps a finger-sized hit area.
Rectangle {
id: side
property string workspace: ""
property string provider: ""
property string model: ""
property string mode: "coding"
property int approval: 1 // cst_approval_t
property bool planMode: false
property bool running: false
property string status: ""
property string todo: ""
signal modeRequested(string mode)
signal approvalRequested(int approval)
signal planModeRequested(bool on)
readonly property alias contentColumn: col
color: Theme.surface
border.width: 1
border.color: Theme.border
implicitHeight: col.implicitHeight + Theme.pad * 2
// One selectable row of a segmented control.
component Seg: Button {
id: seg
property bool current: false
flat: true
implicitHeight: Theme.segmentHeight
padding: 0
background: Rectangle {
radius: Theme.radiusSm
color: seg.current ? Theme.accentDim : (seg.hovered ? Theme.surfaceAlt : "transparent")
border.width: 1
border.color: seg.current ? Theme.accent : Theme.border
}
contentItem: Text {
text: seg.text
color: seg.current ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
Flickable {
anchors.fill: parent
contentWidth: width
contentHeight: col.implicitHeight + Theme.pad * 2
clip: true
boundsBehavior: Flickable.StopAtBounds
ScrollBar.vertical: ScrollBar {}
ColumnLayout {
id: col
x: Theme.pad
y: Theme.pad
width: parent.width - Theme.pad * 2
spacing: Theme.gapL
// --- where ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
RowLayout {
spacing: Theme.gap
Rectangle { width: 8; height: 8; radius: 4; color: Theme.accent }
Text {
text: "CST-agent"
color: Theme.text
font.pixelSize: Theme.bodySize
font.bold: true
}
Item { Layout.fillWidth: true }
Text {
visible: side.running
text: "turn running"
color: Theme.warn
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
text: side.workspace.length ? side.workspace : "(no workspace)"
color: Theme.textDim
font.pixelSize: Theme.smallSize
elide: Text.ElideMiddle
ToolTip.visible: wsHover.hovered
ToolTip.text: side.workspace
HoverHandler { id: wsHover }
}
Text {
Layout.fillWidth: true
visible: side.provider.length > 0 || side.model.length > 0
text: (side.provider.length ? side.provider : "?") +
(side.model.length ? " · " + side.model : "")
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
visible: side.status.length > 0
text: side.status
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
// --- how it frames itself ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Mode"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
RowLayout {
Layout.fillWidth: true
spacing: 4
Seg {
objectName: "modeCoding"
Layout.fillWidth: true
text: "coding"
current: side.mode !== "general"
onClicked: side.modeRequested("coding")
}
Seg {
objectName: "modeGeneral"
Layout.fillWidth: true
text: "general"
current: side.mode === "general"
onClicked: side.modeRequested("general")
}
}
Text {
Layout.fillWidth: true
text: side.mode === "general" ? "documents, data, everyday files"
: "code framed: repos, builds, tests"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- when it asks ---
ColumnLayout {
Layout.fillWidth: true
spacing: 4
Text { text: "Approval"; color: Theme.textDim; font.pixelSize: Theme.smallSize; font.bold: true }
RowLayout {
Layout.fillWidth: true
spacing: 4
Seg {
Layout.fillWidth: true
text: "ask"
current: side.approval === 0
onClicked: side.approvalRequested(0)
}
Seg {
Layout.fillWidth: true
text: "auto"
current: side.approval === 1
onClicked: side.approvalRequested(1)
}
Seg {
Layout.fillWidth: true
text: "yolo"
current: side.approval === 2
onClicked: side.approvalRequested(2)
}
}
Text {
Layout.fillWidth: true
text: side.approval === 0 ? "every tool asks first"
: side.approval === 2 ? "never asks — writes go through"
: "sandbox and rules decide, asks on doubt"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
RowLayout {
Layout.fillWidth: true
Layout.topMargin: 2
spacing: Theme.gap
Switch {
id: planSwitch
objectName: "planSwitch"
checked: side.planMode
implicitHeight: Theme.touchTarget
onClicked: side.planModeRequested(checked)
}
Text {
text: "Plan mode"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Item { Layout.fillWidth: true }
}
Text {
Layout.fillWidth: true
visible: side.planMode
text: "read-only tools and a planning note"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
wrapMode: Text.Wrap
}
}
// --- the plan the engine keeps ---
TodoPanel {
Layout.fillWidth: true
text: side.todo
}
Text {
Layout.fillWidth: true
Layout.topMargin: Theme.gap
text: "engine in-process · v" + Qt.application.version
color: Theme.textFaint
font.pixelSize: Theme.smallSize
elide: Text.ElideRight
}
}
}
}
+60
View File
@@ -0,0 +1,60 @@
// One place for the look: colors, spacing, fonts. The window, the cards and
// the dialogs all read from here, so re-theming (or swapping in Kirigami's
// Kirigami.Theme) is a single file.
pragma Singleton
import QtQuick
QtObject {
// Breeze-dark-ish (the desktop the app is built for), no hard contrast.
readonly property color bg: "#15181a"
readonly property color surface: "#1e2225"
readonly property color surfaceAlt: "#262b2f"
readonly property color surfaceHi: "#313840"
readonly property color border: "#343b41"
readonly property color text: "#eff0f1"
readonly property color textDim: "#9aa2a8"
readonly property color textFaint: "#6d757b"
readonly property color accent: "#3daee9"
readonly property color accentDim: "#1d6f95"
readonly property color ok: "#27ae60"
readonly property color warn: "#f67400"
readonly property color err: "#da4453"
readonly property int radius: 6
readonly property int radiusSm: 3
readonly property int gap: 8
readonly property int gapL: 14
readonly property int pad: 12
readonly property int padL: 16
readonly property int bodySize: 14
readonly property int smallSize: 12
readonly property int monoSize: 13
readonly property string mono: "monospace"
readonly property int sidebarWidth: 268
readonly property int contentMax: 1100
// Layout: below the breakpoint the sidebar slides over the transcript
// instead of taking a column, and controls stay thumb-sized above it
// (a finger needs ~34-40 px, a mouse is happy with 26).
readonly property int compactBreakpoint: 880
readonly property int touchTarget: 34
readonly property int segmentHeight: 30
readonly property int headerHeight: 42
readonly property int jumpButton: 40
// The tool output / diff line colors, used by ToolCard.
function lineColor(line) {
if (line.startsWith("@@"))
return accent
if (line.startsWith("+++") || line.startsWith("---"))
return textDim
if (line.startsWith("+"))
return ok
if (line.startsWith("-"))
return err
return text
}
}
+71
View File
@@ -0,0 +1,71 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The model's reasoning stream, collapsed by default: it is context, not the
// answer.
Rectangle {
id: block
property string text: ""
property bool complete: false
property bool expanded: false
radius: Theme.radius
color: Theme.surfaceAlt
border.width: 1
border.color: Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
opacity: 0.9
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
leftMargin: Theme.pad
rightMargin: Theme.pad
topMargin: Theme.pad / 2
}
spacing: 2
RowLayout {
spacing: Theme.gap
Text {
text: block.expanded ? "▾" : "▸"
color: Theme.textDim
font.pixelSize: Theme.smallSize
}
Text {
text: block.complete ? "thinking" : "thinking…"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
visible: !block.expanded
text: block.text.replace(/\s+/g, " ").trim()
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: block.expanded = !block.expanded
}
}
Text {
Layout.fillWidth: true
Layout.topMargin: 4
visible: block.expanded
text: block.text
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.italic: true
wrapMode: Text.Wrap
textFormat: Text.PlainText
}
}
}
+60
View File
@@ -0,0 +1,60 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// The engine's todo list (on_todo), rendered as a small checklist. The text
// is markdown-ish: "- [x] thing" lines.
ColumnLayout {
id: panel
property string text: ""
function entries() {
return text.split("\n")
.map(function (l) { return l.trim() })
.filter(function (l) { return l.length > 0 })
.map(function (l) {
const done = /^[-*]\s*\[[xX]\]/.test(l) || /^\[[xX]\]/.test(l)
const open = /^[-*]\s*\[\s*\]/.test(l) || /^\[\s*\]/.test(l)
return { done: done, todo: open, label: l.replace(/^[-*]?\s*\[[xX ]?\]\s*/, "").replace(/^[-*]\s*/, "") }
})
}
spacing: 6
Text {
text: "Plan"
color: Theme.textDim
font.pixelSize: Theme.smallSize
font.bold: true
}
Repeater {
model: panel.entries()
RowLayout {
Layout.fillWidth: true
spacing: Theme.gap
Text {
Layout.alignment: Qt.AlignTop
text: modelData.done ? "✓" : "○"
color: modelData.done ? Theme.ok : Theme.textFaint
font.pixelSize: Theme.smallSize
}
Text {
Layout.fillWidth: true
text: modelData.label
color: modelData.done ? Theme.textFaint : Theme.textDim
font.pixelSize: Theme.smallSize
font.strikeout: modelData.done
wrapMode: Text.Wrap
}
}
}
Text {
Layout.fillWidth: true
visible: panel.entries().length === 0
text: "no plan yet"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
+155
View File
@@ -0,0 +1,155 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
// A tool call: what ran, whether it is still running, and what came back.
// Long output stays folded until asked for, diff lines are colored.
Rectangle {
id: card
property string name: ""
property string state: "pending" // pending | ready | running | done | denied
property string detail: ""
property string output: ""
property int exitCode: 0
property bool hasResult: false
property bool expanded: false
readonly property bool running: state === "pending" || state === "ready" || state === "running"
readonly property color stateColor: state === "denied" ? Theme.err
: running ? Theme.warn
: hasResult && exitCode !== 0 ? Theme.err
: Theme.ok
readonly property string stateText: state === "pending" ? "reading"
: state === "ready" ? "queued"
: state === "running" ? "running"
: state === "denied" ? "denied"
: hasResult && exitCode !== 0 ? "failed" : "done"
function lines() {
const all = output.split("\n")
return all.length > 500 ? all.slice(0, 500).concat(["… " + (all.length - 500) + " more lines"]) : all
}
radius: Theme.radius
color: Theme.surfaceAlt
border.width: 1
border.color: expanded ? Theme.stateColor : Theme.border
implicitHeight: layout.implicitHeight + Theme.pad
ColumnLayout {
id: layout
anchors {
left: parent.left
right: parent.right
top: parent.top
margins: Theme.pad
}
spacing: 6
RowLayout {
spacing: Theme.gap
BusyIndicator {
visible: card.running
running: card.running
implicitWidth: 16
implicitHeight: 16
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
visible: !card.running
Layout.alignment: Qt.AlignVCenter
width: 6
height: 6
radius: 3
color: card.stateColor
}
Text {
text: card.name
color: Theme.text
font.family: Theme.mono
font.pixelSize: Theme.monoSize
font.bold: true
}
Rectangle {
Layout.alignment: Qt.AlignVCenter
radius: Theme.radiusSm
color: "transparent"
border.width: 1
border.color: card.stateColor
implicitWidth: chip.implicitWidth + 10
implicitHeight: chip.implicitHeight + 2
Text {
id: chip
anchors.centerIn: parent
text: card.stateText + (card.hasResult ? " · exit " + card.exitCode : "")
color: card.stateColor
font.pixelSize: Theme.smallSize
}
}
Text {
Layout.fillWidth: true
elide: Text.ElideRight
text: card.detail.replace(/\s+/g, " ").trim()
color: Theme.textFaint
font.family: Theme.mono
font.pixelSize: Theme.smallSize
}
ToolButton {
implicitWidth: 26
implicitHeight: 22
visible: card.output.length > 0
contentItem: Text {
text: card.expanded ? "▾" : "▸"
color: parent.hovered ? Theme.text : Theme.textDim
font.pixelSize: Theme.smallSize
horizontalAlignment: Text.AlignHCenter
}
background: Rectangle {
radius: Theme.radiusSm
color: parent.hovered ? Theme.surfaceHi : "transparent"
}
onClicked: card.expanded = !card.expanded
}
}
// Folded view: the first line of the result, so a done card still says
// something without being opened.
Text {
Layout.fillWidth: true
visible: !card.expanded && card.output.length > 0
text: {
const l = card.output.split("\n")
return l[0] + (l.length > 1 ? " …" : "")
}
elide: Text.ElideRight
color: Theme.textFaint
font.family: Theme.mono
font.pixelSize: Theme.smallSize
}
ColumnLayout {
Layout.fillWidth: true
visible: card.expanded && card.output.length > 0
spacing: 0
Repeater {
model: card.expanded ? card.lines() : []
Text {
Layout.fillWidth: true
text: modelData.length ? modelData : " "
color: Theme.lineColor(modelData)
font.family: Theme.mono
font.pixelSize: Theme.monoSize
wrapMode: Text.NoWrap
textFormat: Text.PlainText
}
}
Text {
Layout.topMargin: 4
visible: card.output.split("\n").length > 500
text: "output truncated for the view"
color: Theme.textFaint
font.pixelSize: Theme.smallSize
}
}
}
}