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
605 lines
21 KiB
QML
605 lines
21 KiB
QML
// 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
|
|
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 })
|
|
}
|
|
|
|
// 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 })
|
|
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) {
|
|
const err = agent.useProvider(provider, model, key)
|
|
settings.note = err.length ? err
|
|
: "using " + agent.provider + " / " + agent.model
|
|
settings.choices = agent.modelChoices(provider)
|
|
settings.syncPick()
|
|
}
|
|
|
|
Component.onCompleted: win.reloadSessions()
|
|
|
|
// ---- 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
|
|
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 = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
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) {
|
|
win.applySettings(provider, model, key)
|
|
}
|
|
}
|
|
|
|
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 ----------------------------------------------------
|
|
/* 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 = ""
|
|
}
|
|
|
|
/* 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 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()
|
|
}
|
|
|
|
function onTurnFinished(rc, err) { win.engineTurnFinished(rc, err) }
|
|
|
|
function onRunningChanged() {
|
|
if (!agent.running)
|
|
win.closeAllRows()
|
|
}
|
|
}
|
|
}
|