A Controls ToolBar hands its children to a style-owned contentItem, and on the NX789J that item sat 35 px below the bar itself: the header was at y 0 h 42 while its own gear was at y 56 -- drawn on top of the sidebar's first lines, and clicked there too, so the settings dialog could not be opened from the gear at all (eleven checks that follow it failed with it). A desktop never showed it: same style (Basic), same numbers, gear at y 21. A plain Rectangle has no such indirection -- its children are where it is. Nothing else changed; the background and the border line are the same. --uitest prints what it sees at that point now (style, window, header, gear, safe area), which is how this was found: note style Basic, window 1280x853, header y 0 h 42, gear centre y 21 Before: gear centre y 56, `FAIL the gear opens the settings dialog`, 17 failures on the device. After: 95 ok, 4 FAIL (Open/Share and the two clipboard-image checks, which Android has no answer for).
674 lines
24 KiB
QML
674 lines
24 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, 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 = ""
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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, 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 = ""
|
|
}
|
|
}
|
|
|
|
// ---- 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()
|
|
}
|
|
}
|
|
}
|