1541 lines
60 KiB
QML
1541 lines
60 KiB
QML
import QtQuick 2.15
|
|
import QtQuick.Controls 2.15
|
|
import QtQuick.Layouts 1.15
|
|
|
|
ApplicationWindow {
|
|
id: root
|
|
visible: true
|
|
width: 1000
|
|
height: 600
|
|
title: "Ollie"
|
|
font.family: theme.fontFamily || Qt.application.font.family
|
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
|
|
|
// Apply theme palette to all Qt Quick Controls
|
|
palette.active.window: theme.windowColor
|
|
palette.active.windowText: theme.windowTextColor
|
|
palette.active.base: theme.baseColor
|
|
palette.active.text: theme.textColor
|
|
palette.active.button: theme.buttonColor
|
|
palette.active.buttonText: theme.buttonTextColor
|
|
palette.active.highlight: theme.highlightColor
|
|
palette.active.highlightedText: theme.highlightedTextColor
|
|
palette.active.mid: theme.midColor
|
|
palette.active.dark: theme.darkColor
|
|
palette.active.light: theme.lightColor
|
|
palette.active.shadow: theme.shadowColor
|
|
palette.active.link: theme.linkColor
|
|
palette.active.toolTipBase: theme.tooltipBaseColor
|
|
palette.active.toolTipText: theme.tooltipTextColor
|
|
palette.active.placeholderText: theme.placeholderTextColor
|
|
palette.active.alternateBase: theme.alternateBaseColor
|
|
palette.inactive.window: theme.windowColor
|
|
palette.inactive.windowText: theme.windowTextColor
|
|
palette.inactive.base: theme.baseColor
|
|
palette.inactive.text: theme.textColor
|
|
palette.inactive.button: theme.buttonColor
|
|
palette.inactive.buttonText: theme.buttonTextColor
|
|
palette.inactive.highlight: theme.highlightColor
|
|
palette.inactive.highlightedText: theme.highlightedTextColor
|
|
palette.inactive.mid: theme.midColor
|
|
palette.inactive.dark: theme.darkColor
|
|
palette.inactive.light: theme.lightColor
|
|
palette.inactive.shadow: theme.shadowColor
|
|
palette.inactive.link: theme.linkColor
|
|
palette.inactive.toolTipBase: theme.tooltipBaseColor
|
|
palette.inactive.toolTipText: theme.tooltipTextColor
|
|
palette.inactive.placeholderText: theme.placeholderTextColor
|
|
palette.inactive.alternateBase: theme.alternateBaseColor
|
|
palette.disabled.window: theme.windowColor
|
|
palette.disabled.windowText: theme.midColor
|
|
palette.disabled.base: theme.baseColor
|
|
palette.disabled.text: theme.midColor
|
|
palette.disabled.button: theme.buttonColor
|
|
palette.disabled.buttonText: theme.midColor
|
|
palette.disabled.highlight: theme.highlightColor
|
|
palette.disabled.highlightedText: theme.highlightedTextColor
|
|
palette.disabled.mid: theme.midColor
|
|
palette.disabled.dark: theme.darkColor
|
|
palette.disabled.light: theme.lightColor
|
|
palette.disabled.shadow: theme.shadowColor
|
|
|
|
Shortcut {
|
|
sequence: "Ctrl+B"
|
|
onActivated: root.sidebarVisible = !root.sidebarVisible
|
|
}
|
|
|
|
Shortcut {
|
|
sequence: "Escape"
|
|
onActivated: {
|
|
if (renamingIndex >= 0) {
|
|
cancelRename()
|
|
} else {
|
|
// Always send stop - server-side Interrupt() is safe to call
|
|
// on an idle agent (no-op). This avoids races where the GUI's
|
|
// cached activeState is stale.
|
|
ollie.interrupt()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- Slash command definitions ---
|
|
readonly property var slashCommands: [
|
|
{ name: "model", desc: "Set model" },
|
|
{ name: "backend", desc: "Set backend" },
|
|
{ name: "agent", desc: "Switch agent" },
|
|
{ name: "stop", desc: "Stop current run" },
|
|
{ name: "kill", desc: "Kill session" },
|
|
{ name: "new", desc: "New session" },
|
|
{ name: "compact", desc: "Compact context" },
|
|
{ name: "help", desc: "Show help" }
|
|
]
|
|
|
|
// Sidebar visibility
|
|
property bool sidebarVisible: true
|
|
|
|
// Deferred scroll timer — fires once after model changes settle
|
|
Timer {
|
|
id: scrollTimer
|
|
interval: 16 // ~1 frame at 60fps, allows layout to settle
|
|
repeat: false
|
|
onTriggered: {
|
|
if (chatView.shouldAutoScroll)
|
|
chatView.positionViewAtEnd()
|
|
}
|
|
}
|
|
|
|
// Session ID to kill — set on right-click, consumed by context menu
|
|
property string killTargetSessionId: ""
|
|
// Target session for "New Agent" dialog
|
|
property string newAgentTargetSessionId: ""
|
|
// Agent kill target — set on right-click
|
|
property string killTargetAgentSessionId: ""
|
|
property string killTargetAgentId: ""
|
|
|
|
// Session tree model — flat list with level info
|
|
ListModel {
|
|
id: sessionTreeModel
|
|
}
|
|
|
|
// Cache for session expanded state (sessionId -> bool)
|
|
property var sessionExpandedCache: ({})
|
|
|
|
// Cache of current model structure for diffing: { sessionId: { agents: [agentId, ...] } }
|
|
property var modelCache: ({})
|
|
|
|
function buildSessionTree() {
|
|
// Full rebuild - used on initial load
|
|
var hadFocus = promptInput.activeFocus
|
|
sessionTreeModel.clear()
|
|
modelCache = {}
|
|
var sessions = ollie.sessions
|
|
|
|
// Sessions are already sorted: active first, paused last.
|
|
// Insert group headers when paused state changes.
|
|
var lastPaused = null
|
|
for (var i = 0; i < sessions.length; ++i) {
|
|
var sess = sessions[i]
|
|
var isPaused = sess.paused || false
|
|
|
|
// Insert header on transition
|
|
if (lastPaused === null && !isPaused) {
|
|
sessionTreeModel.append({
|
|
type: "header",
|
|
level: 0,
|
|
displayName: "Active"
|
|
})
|
|
} else if (lastPaused === false && isPaused) {
|
|
sessionTreeModel.append({
|
|
type: "header",
|
|
level: 0,
|
|
displayName: "Inactive"
|
|
})
|
|
} else if (lastPaused === null && isPaused) {
|
|
// All sessions are paused - show Inactive header
|
|
sessionTreeModel.append({
|
|
type: "header",
|
|
level: 0,
|
|
displayName: "Inactive"
|
|
})
|
|
}
|
|
lastPaused = isPaused
|
|
|
|
appendSessionToModel(sess)
|
|
}
|
|
if (hadFocus)
|
|
promptInput.forceActiveFocus()
|
|
}
|
|
|
|
// Compute connection color from session data (avoids C++ call)
|
|
function computeConnectionColor(sess) {
|
|
var paused = sess.paused || false
|
|
var connected = sess.connected || false
|
|
if (paused && connected) return "yellow" // zombie
|
|
if (paused) return "gray" // intentionally paused
|
|
if (connected) return "green" // healthy
|
|
return "red" // crashed/broken
|
|
}
|
|
|
|
function appendSessionToModel(sess) {
|
|
var sessionId = sess.id || ""
|
|
var sessionName = sess.name || sessionId
|
|
var agents = sess.agents || []
|
|
var agentCount = agents.length
|
|
var displayLabel = agentCount > 0 ? sessionName + " (" + agentCount + ")" : sessionName
|
|
var wasExpanded = sessionExpandedCache[sessionId] !== undefined ? sessionExpandedCache[sessionId] : true
|
|
sessionExpandedCache[sessionId] = wasExpanded
|
|
var isPaused = sess.paused || false
|
|
|
|
sessionTreeModel.append({
|
|
type: "session",
|
|
id: sessionId,
|
|
level: 0,
|
|
expanded: wasExpanded,
|
|
state: sess.state || "",
|
|
model: sess.model || "",
|
|
displayName: displayLabel,
|
|
baseName: sessionName,
|
|
connectionColor: computeConnectionColor(sess),
|
|
paused: isPaused
|
|
})
|
|
|
|
var agentIds = []
|
|
for (var j = 0; j < agents.length; ++j) {
|
|
var agent = agents[j]
|
|
// Use state from session/idx data directly (already contains agent state)
|
|
sessionTreeModel.append({
|
|
type: "agent",
|
|
parentId: sessionId,
|
|
agentId: agent.id,
|
|
level: 1,
|
|
state: agent.state || "idle",
|
|
displayName: agent.name,
|
|
parentExpanded: wasExpanded,
|
|
parentPaused: isPaused
|
|
})
|
|
agentIds.push(agent.id)
|
|
}
|
|
modelCache[sessionId] = { agents: agentIds }
|
|
}
|
|
|
|
function updateSessionTree() {
|
|
// Incremental update - diff old vs new
|
|
var hadFocus = promptInput.activeFocus
|
|
var sessions = ollie.sessions
|
|
|
|
// Build lookup of new sessions
|
|
var newSessionIds = {}
|
|
for (var i = 0; i < sessions.length; ++i) {
|
|
newSessionIds[sessions[i].id] = sessions[i]
|
|
}
|
|
|
|
// Build lookup of old sessions from cache
|
|
var oldSessionIds = Object.keys(modelCache)
|
|
|
|
// Find removed sessions and delete from model (iterate backwards to preserve indices)
|
|
for (var idx = sessionTreeModel.count - 1; idx >= 0; --idx) {
|
|
var item = sessionTreeModel.get(idx)
|
|
if (item.type === "session" && !newSessionIds[item.id]) {
|
|
// Remove session and all its agents
|
|
removeSessionFromModel(idx, item.id)
|
|
}
|
|
}
|
|
|
|
// Update existing sessions and add new ones
|
|
for (var j = 0; j < sessions.length; ++j) {
|
|
var sess = sessions[j]
|
|
var sessionId = sess.id
|
|
|
|
if (modelCache[sessionId]) {
|
|
// Session exists - update properties
|
|
updateSessionInModel(sess)
|
|
} else {
|
|
// New session - append at end
|
|
appendSessionToModel(sess)
|
|
}
|
|
}
|
|
|
|
if (hadFocus)
|
|
promptInput.forceActiveFocus()
|
|
}
|
|
|
|
function findSessionIndex(sessionId) {
|
|
for (var i = 0; i < sessionTreeModel.count; ++i) {
|
|
var item = sessionTreeModel.get(i)
|
|
if (item.type === "session" && item.id === sessionId)
|
|
return i
|
|
}
|
|
return -1
|
|
}
|
|
|
|
function removeSessionFromModel(sessionIdx, sessionId) {
|
|
// Count agents to remove (they follow the session)
|
|
var removeCount = 1
|
|
for (var i = sessionIdx + 1; i < sessionTreeModel.count; ++i) {
|
|
var item = sessionTreeModel.get(i)
|
|
if (item.type === "session") break
|
|
if (item.type === "agent" && item.parentId === sessionId)
|
|
removeCount++
|
|
}
|
|
// Remove session + agents
|
|
for (var r = 0; r < removeCount; ++r) {
|
|
sessionTreeModel.remove(sessionIdx)
|
|
}
|
|
delete modelCache[sessionId]
|
|
delete sessionExpandedCache[sessionId]
|
|
}
|
|
|
|
function updateSessionInModel(sess) {
|
|
var sessionId = sess.id
|
|
var sessionIdx = findSessionIndex(sessionId)
|
|
if (sessionIdx < 0) return
|
|
|
|
var sessionName = sess.name || sessionId
|
|
var agents = sess.agents || []
|
|
var agentCount = agents.length
|
|
var displayLabel = agentCount > 0 ? sessionName + " (" + agentCount + ")" : sessionName
|
|
var isPaused = sess.paused || false
|
|
var wasExpanded = sessionExpandedCache[sessionId] !== undefined ? sessionExpandedCache[sessionId] : true
|
|
|
|
// Update session properties (use QML-computed color, avoids C++ call)
|
|
sessionTreeModel.setProperty(sessionIdx, "displayName", displayLabel)
|
|
sessionTreeModel.setProperty(sessionIdx, "baseName", sessionName)
|
|
sessionTreeModel.setProperty(sessionIdx, "state", sess.state || "")
|
|
sessionTreeModel.setProperty(sessionIdx, "model", sess.model || "")
|
|
sessionTreeModel.setProperty(sessionIdx, "connectionColor", computeConnectionColor(sess))
|
|
sessionTreeModel.setProperty(sessionIdx, "paused", isPaused)
|
|
|
|
// Build agent lookup
|
|
var newAgentIds = {}
|
|
for (var i = 0; i < agents.length; ++i) {
|
|
newAgentIds[agents[i].id] = agents[i]
|
|
}
|
|
var oldAgentIds = modelCache[sessionId] ? modelCache[sessionId].agents : []
|
|
|
|
// Update/remove existing agents
|
|
for (var idx = sessionIdx + 1; idx < sessionTreeModel.count; ++idx) {
|
|
var item = sessionTreeModel.get(idx)
|
|
if (item.type === "session") break
|
|
if (item.type === "agent" && item.parentId === sessionId) {
|
|
if (newAgentIds[item.agentId]) {
|
|
// Update agent - use state from session/idx directly
|
|
var agent = newAgentIds[item.agentId]
|
|
sessionTreeModel.setProperty(idx, "state", agent.state || "idle")
|
|
sessionTreeModel.setProperty(idx, "displayName", agent.name)
|
|
sessionTreeModel.setProperty(idx, "parentPaused", isPaused)
|
|
} else {
|
|
// Remove agent
|
|
sessionTreeModel.remove(idx)
|
|
--idx // Adjust for removed item
|
|
}
|
|
}
|
|
}
|
|
|
|
// Add new agents (at end of session's agents)
|
|
var insertIdx = sessionIdx + 1
|
|
for (var k = insertIdx; k < sessionTreeModel.count; ++k) {
|
|
var checkItem = sessionTreeModel.get(k)
|
|
if (checkItem.type === "session") break
|
|
insertIdx = k + 1
|
|
}
|
|
|
|
var newAgentList = []
|
|
for (var m = 0; m < agents.length; ++m) {
|
|
var ag = agents[m]
|
|
newAgentList.push(ag.id)
|
|
var found = false
|
|
for (var n = 0; n < oldAgentIds.length; ++n) {
|
|
if (oldAgentIds[n] === ag.id) { found = true; break }
|
|
}
|
|
if (!found) {
|
|
// Use state from session/idx directly
|
|
sessionTreeModel.insert(insertIdx, {
|
|
type: "agent",
|
|
parentId: sessionId,
|
|
agentId: ag.id,
|
|
level: 1,
|
|
state: ag.state || "idle",
|
|
displayName: ag.name,
|
|
parentExpanded: wasExpanded,
|
|
parentPaused: isPaused
|
|
})
|
|
}
|
|
}
|
|
modelCache[sessionId] = { agents: newAgentList }
|
|
}
|
|
|
|
function toggleExpand(index) {
|
|
var item = sessionTreeModel.get(index)
|
|
if (!item.expanded === undefined) return // only expandable items
|
|
|
|
var newExpanded = !item.expanded
|
|
var itemLevel = item.level || 0
|
|
sessionTreeModel.setProperty(index, "expanded", newExpanded)
|
|
|
|
// Cache for sessions (by id)
|
|
if (item.type === "session" && item.id) {
|
|
sessionExpandedCache[item.id] = newExpanded
|
|
}
|
|
|
|
// Update all descendants: items with level > itemLevel until we hit same or lower level
|
|
for (var i = index + 1; i < sessionTreeModel.count; ++i) {
|
|
var child = sessionTreeModel.get(i)
|
|
var childLevel = child.level || 0
|
|
if (childLevel <= itemLevel) break // end of descendants
|
|
|
|
// Determine visibility: walk up to find if any ancestor is collapsed
|
|
var visible = newExpanded
|
|
if (visible) {
|
|
// Check all ancestors between this item and the child
|
|
for (var j = i - 1; j >= index; --j) {
|
|
var ancestor = sessionTreeModel.get(j)
|
|
var ancestorLevel = ancestor.level || 0
|
|
if (ancestorLevel < childLevel && ancestor.expanded === false) {
|
|
visible = false
|
|
break
|
|
}
|
|
if (ancestorLevel < childLevel) {
|
|
childLevel = ancestorLevel // track the level we've checked up to
|
|
}
|
|
}
|
|
}
|
|
sessionTreeModel.setProperty(i, "parentExpanded", visible)
|
|
}
|
|
}
|
|
|
|
function activateAgent(parentId, agentId) {
|
|
ollie.switchAgent(parentId, agentId)
|
|
}
|
|
|
|
// Rename tracking
|
|
property int renamingIndex: -1
|
|
property string renamingText: ""
|
|
|
|
function startRename(index) {
|
|
var item = sessionTreeModel.get(index)
|
|
if (item.type === "session" || item.type === "agent") {
|
|
renamingIndex = index
|
|
// Use baseName for sessions (without agent count), displayName for agents
|
|
renamingText = item.type === "session" ? (item.baseName || item.displayName) : item.displayName
|
|
}
|
|
}
|
|
|
|
function commitRename() {
|
|
if (renamingIndex < 0) return
|
|
var item = sessionTreeModel.get(renamingIndex)
|
|
var newName = renamingText.trim()
|
|
// For sessions, compare against baseName (without count)
|
|
var oldName = item.type === "session" ? (item.baseName || item.displayName) : item.displayName
|
|
if (newName !== "" && newName !== oldName) {
|
|
var success = false
|
|
if (item.type === "session") {
|
|
success = ollie.renameSession(item.id, newName)
|
|
// Don't update displayName here - let refreshSessions rebuild with count
|
|
} else if (item.type === "agent") {
|
|
success = ollie.renameAgent(item.parentId, item.agentId, newName)
|
|
if (success) {
|
|
sessionTreeModel.set(renamingIndex, { agentId: newName, displayName: newName })
|
|
}
|
|
}
|
|
if (!success) {
|
|
renameErrorDialog.itemType = item.type
|
|
renameErrorDialog.attemptedName = newName
|
|
renameErrorDialog.open()
|
|
}
|
|
}
|
|
renamingIndex = -1
|
|
renamingText = ""
|
|
}
|
|
|
|
function cancelRename() {
|
|
renamingIndex = -1
|
|
renamingText = ""
|
|
}
|
|
|
|
Connections {
|
|
target: ollie
|
|
function onSessionsChanged() {
|
|
// Always do full rebuild since headers complicate incremental updates
|
|
buildSessionTree()
|
|
}
|
|
function onAgentStateChanged(sessionId, agentId, state) {
|
|
// Update only the specific agent in the model
|
|
for (var i = 0; i < sessionTreeModel.count; ++i) {
|
|
var item = sessionTreeModel.get(i)
|
|
if (item.type === "agent" && item.parentId === sessionId && item.agentId === agentId) {
|
|
sessionTreeModel.setProperty(i, "state", state)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
if (ollie.activeAgentId) {
|
|
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
|
|
if (log) {
|
|
chatModel.appendData(log)
|
|
chatModel.trimFront(20)
|
|
}
|
|
}
|
|
scrollTimer.start()
|
|
// Ensure we have the latest session list
|
|
ollie.refreshSessions()
|
|
buildSessionTree()
|
|
}
|
|
|
|
Connections {
|
|
target: ollie
|
|
function onActiveSessionIdChanged() {
|
|
chatModel.reset()
|
|
chatView.shouldAutoScroll = true // Reset scroll state on session switch
|
|
if (ollie.activeSessionId && ollie.activeAgentId) {
|
|
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
|
|
if (log) {
|
|
chatModel.appendData(log)
|
|
chatModel.trimFront(20)
|
|
}
|
|
}
|
|
scrollTimer.start()
|
|
}
|
|
function onActiveAgentIdChanged() {
|
|
chatModel.reset()
|
|
chatView.shouldAutoScroll = true // Reset scroll state on agent switch
|
|
if (ollie.activeSessionId && ollie.activeAgentId) {
|
|
var log = ollie.readLogForSession(ollie.activeSessionId, ollie.activeAgentId)
|
|
if (log) {
|
|
chatModel.appendData(log)
|
|
chatModel.trimFront(20)
|
|
}
|
|
}
|
|
scrollTimer.start()
|
|
}
|
|
}
|
|
|
|
// --- Rename Error Dialog ---
|
|
Dialog {
|
|
id: renameErrorDialog
|
|
title: "Rename Failed"
|
|
anchors.centerIn: parent
|
|
width: 320
|
|
modal: true
|
|
standardButtons: Dialog.Ok
|
|
|
|
property string itemType: "session"
|
|
property string attemptedName: ""
|
|
|
|
contentItem: Label {
|
|
text: "Could not rename " + renameErrorDialog.itemType + " to \"" + renameErrorDialog.attemptedName + "\".\n\nThe name may already be in use."
|
|
wrapMode: Text.Wrap
|
|
}
|
|
}
|
|
|
|
// --- Settings Dialog ---
|
|
Dialog {
|
|
id: settingsDialog
|
|
title: "Settings"
|
|
anchors.centerIn: parent
|
|
width: 380
|
|
modal: true
|
|
standardButtons: Dialog.Close
|
|
|
|
contentItem: ColumnLayout {
|
|
spacing: 16
|
|
Layout.margins: 16
|
|
|
|
Label {
|
|
text: "Color Scheme"
|
|
font.bold: true
|
|
}
|
|
|
|
Label {
|
|
text: "Uses KDE Breeze color schemes from /usr/share/color-schemes/"
|
|
opacity: 0.6
|
|
wrapMode: Text.Wrap
|
|
Layout.fillWidth: true
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.9
|
|
}
|
|
|
|
ColumnLayout {
|
|
spacing: 8
|
|
|
|
RadioButton {
|
|
id: systemRadio
|
|
text: "Use system theme"
|
|
checked: theme.mode === "system"
|
|
onClicked: theme.mode = "system"
|
|
}
|
|
RadioButton {
|
|
id: lightRadio
|
|
text: "Breeze Light"
|
|
checked: theme.mode === "light"
|
|
onClicked: theme.mode = "light"
|
|
}
|
|
RadioButton {
|
|
id: darkRadio
|
|
text: "Breeze Dark"
|
|
checked: theme.mode === "dark"
|
|
onClicked: theme.mode = "dark"
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: theme.isDark ? "Current: dark" : "Current: light"
|
|
opacity: 0.5
|
|
font.pixelSize: Qt.application.font.pixelSize * 0.85
|
|
}
|
|
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 1
|
|
color: palette.mid
|
|
Layout.topMargin: 8
|
|
}
|
|
|
|
Label {
|
|
text: "Font"
|
|
font.bold: true
|
|
}
|
|
|
|
GridLayout {
|
|
columns: 2
|
|
columnSpacing: 12
|
|
rowSpacing: 8
|
|
Layout.fillWidth: true
|
|
|
|
Label { text: "Family:" }
|
|
ComboBox {
|
|
id: fontFamilyCombo
|
|
Layout.fillWidth: true
|
|
model: theme.availableFamilies
|
|
editable: true
|
|
currentIndex: {
|
|
let fam = theme.fontFamily || Qt.application.font.family
|
|
let idx = model.indexOf(fam)
|
|
return idx >= 0 ? idx : 0
|
|
}
|
|
onActivated: theme.fontFamily = currentText
|
|
onAccepted: theme.fontFamily = editText
|
|
}
|
|
|
|
Label { text: "Style:" }
|
|
ComboBox {
|
|
id: fontStyleCombo
|
|
Layout.fillWidth: true
|
|
model: theme.availableStyles
|
|
currentIndex: {
|
|
let st = theme.fontStyle
|
|
let idx = model.indexOf(st)
|
|
return idx >= 0 ? idx : 0
|
|
}
|
|
onActivated: theme.fontStyle = currentText
|
|
}
|
|
|
|
Label { text: "Size:" }
|
|
SpinBox {
|
|
id: fontSizeSpin
|
|
from: 6
|
|
to: 72
|
|
value: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
|
onValueModified: theme.fontSize = value
|
|
}
|
|
}
|
|
|
|
Button {
|
|
text: "Reset to system font"
|
|
onClicked: theme.resetFont()
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- New Session Dialog ---
|
|
Dialog {
|
|
id: newSessionDialog
|
|
title: "New Session"
|
|
anchors.centerIn: parent
|
|
width: 380
|
|
modal: true
|
|
|
|
contentItem: ColumnLayout {
|
|
spacing: 16
|
|
Layout.margins: 16
|
|
|
|
Label {
|
|
id: newSessionErrorLabel
|
|
Layout.fillWidth: true
|
|
visible: text !== ""
|
|
color: "red"
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
// Name
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Label { text: "Name:"; Layout.preferredWidth: 80 }
|
|
TextField {
|
|
id: sessionNameField
|
|
Layout.fillWidth: true
|
|
placeholderText: "(auto-generated)"
|
|
}
|
|
}
|
|
|
|
// Remote
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Label { text: "Remote:"; Layout.preferredWidth: 80 }
|
|
TextField {
|
|
id: sessionRemoteField
|
|
Layout.fillWidth: true
|
|
placeholderText: "user@host (optional)"
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: RowLayout {
|
|
Layout.fillWidth: true
|
|
Item { Layout.fillWidth: true }
|
|
Button {
|
|
text: "OK"
|
|
onClicked: {
|
|
newSessionErrorLabel.text = ""
|
|
var name = sessionNameField.text.trim()
|
|
var remote = sessionRemoteField.text.trim()
|
|
|
|
var err = ollie.createSession(name, remote)
|
|
if (err === "") {
|
|
ollie.refreshSessions()
|
|
newSessionDialog.close()
|
|
} else {
|
|
newSessionErrorLabel.text = err
|
|
}
|
|
}
|
|
}
|
|
Button {
|
|
text: "Cancel"
|
|
onClicked: newSessionDialog.close()
|
|
}
|
|
}
|
|
|
|
onOpened: {
|
|
newSessionErrorLabel.text = ""
|
|
sessionNameField.text = ""
|
|
sessionRemoteField.text = ""
|
|
}
|
|
}
|
|
|
|
// --- New Agent Dialog ---
|
|
Dialog {
|
|
id: newAgentDialog
|
|
title: "New Agent"
|
|
anchors.centerIn: parent
|
|
width: 420
|
|
modal: true
|
|
|
|
contentItem: ColumnLayout {
|
|
spacing: 16
|
|
Layout.margins: 16
|
|
|
|
// --- Agent ---
|
|
Label { text: "Agent"; font.bold: true; font.pointSize: theme.fontSize > 0 ? theme.fontSize + 1 : Qt.application.font.pointSize + 1 }
|
|
|
|
// Error label
|
|
Label {
|
|
id: newAgentErrorLabel
|
|
Layout.fillWidth: true
|
|
visible: text !== ""
|
|
color: "red"
|
|
wrapMode: Text.Wrap
|
|
}
|
|
|
|
// Directory
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Label { text: "Directory:"; Layout.preferredWidth: 80 }
|
|
TextField {
|
|
id: dirField
|
|
Layout.fillWidth: true
|
|
placeholderText: "/path/to/project"
|
|
readOnly: false
|
|
}
|
|
Button {
|
|
text: "Browse..."
|
|
onClicked: {
|
|
var folder = theme.chooseFolder("Select Project Directory", dirField.text || "")
|
|
if (folder !== "")
|
|
dirField.text = folder
|
|
}
|
|
}
|
|
}
|
|
|
|
// Backend
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Label { text: "Backend:"; Layout.preferredWidth: 80 }
|
|
ComboBox {
|
|
id: backendCombo
|
|
Layout.fillWidth: true
|
|
enabled: ollie.rootBackendsLoaded
|
|
model: ollie.availableBackends
|
|
currentIndex: 0
|
|
editable: true
|
|
onAccepted: backendCombo.editText = backendCombo.currentText
|
|
}
|
|
}
|
|
|
|
// Model list (populated reactively from backend selection)
|
|
ListModel { id: modelList }
|
|
|
|
// Model
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Label { text: "Model:"; Layout.preferredWidth: 80 }
|
|
ComboBox {
|
|
id: modelCombo
|
|
Layout.fillWidth: true
|
|
enabled: backendCombo.currentIndex >= 0 && backendCombo.currentText.trim() !== "" && ollie.rootModelsLoaded
|
|
model: modelList
|
|
currentIndex: -1
|
|
editable: true
|
|
onAccepted: modelCombo.editText = modelCombo.currentText
|
|
}
|
|
}
|
|
|
|
// Profile
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Label { text: "Profile:"; Layout.preferredWidth: 80 }
|
|
ComboBox {
|
|
id: agentCombo
|
|
Layout.fillWidth: true
|
|
enabled: ollie.rootAgentsLoaded
|
|
model: ollie.availableAgents
|
|
currentIndex: 0
|
|
}
|
|
}
|
|
|
|
// Alias
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Label { text: "Name:"; Layout.preferredWidth: 80 }
|
|
TextField {
|
|
id: agentAliasField
|
|
Layout.fillWidth: true
|
|
placeholderText: "(auto-generated)"
|
|
}
|
|
}
|
|
|
|
// Remote
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Label { text: "Remote:"; Layout.preferredWidth: 80 }
|
|
TextField {
|
|
id: remoteField
|
|
Layout.fillWidth: true
|
|
placeholderText: "user@host:port"
|
|
}
|
|
}
|
|
|
|
Component.onCompleted: {
|
|
if (!ollie.rootBackendsLoaded) ollie.loadRootBackends()
|
|
if (!ollie.rootAgentsLoaded) ollie.loadRootAgents()
|
|
}
|
|
|
|
Connections {
|
|
target: backendCombo
|
|
function onCurrentTextChanged() {
|
|
var be = backendCombo.currentText.trim()
|
|
modelList.clear()
|
|
if (be !== "" && ollie.rootModelsLoaded) {
|
|
var models = ollie.getAvailableModels(be)
|
|
for (var i = 0; i < models.length; ++i) {
|
|
modelList.append({ modelData: models[i] })
|
|
}
|
|
if (models.length > 0) {
|
|
modelCombo.currentIndex = 0
|
|
} else {
|
|
modelCombo.currentIndex = -1
|
|
}
|
|
} else {
|
|
modelCombo.currentIndex = -1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
footer: RowLayout {
|
|
Layout.fillWidth: true
|
|
Item { Layout.fillWidth: true }
|
|
Button {
|
|
text: "OK"
|
|
onClicked: {
|
|
newAgentErrorLabel.text = ""
|
|
var cwd = dirField.text.trim()
|
|
if (cwd === "") {
|
|
newAgentErrorLabel.text = "Directory is required"
|
|
return
|
|
}
|
|
var backend = backendCombo.currentText.trim()
|
|
var model = modelCombo.currentIndex >= 0 ? modelCombo.currentText.trim() : ""
|
|
var agent = agentCombo.currentText
|
|
var remote = remoteField.text.trim()
|
|
var agentAlias = agentAliasField.text.trim()
|
|
|
|
var err = ollie.createAgent(root.newAgentTargetSessionId, cwd, backend, model, agent, remote, agentAlias)
|
|
if (err === "") {
|
|
ollie.refreshSessions()
|
|
newAgentDialog.close()
|
|
} else {
|
|
newAgentErrorLabel.text = err
|
|
}
|
|
}
|
|
}
|
|
Button {
|
|
text: "Cancel"
|
|
onClicked: newAgentDialog.close()
|
|
}
|
|
}
|
|
|
|
onOpened: newAgentErrorLabel.text = ""
|
|
}
|
|
|
|
SplitView {
|
|
anchors.fill: parent
|
|
|
|
// Left pane: session list
|
|
ColumnLayout {
|
|
id: sidebarPane
|
|
SplitView.preferredWidth: root.sidebarVisible ? root.width * 0.15 : 0
|
|
SplitView.minimumWidth: root.sidebarVisible ? 300 : 0
|
|
SplitView.maximumWidth: root.sidebarVisible ? Infinity : 0
|
|
visible: root.sidebarVisible
|
|
spacing: 0
|
|
|
|
ToolBar {
|
|
Layout.fillWidth: true
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
Label { text: "Sessions"; Layout.fillWidth: true; font.bold: true; leftPadding: 8 }
|
|
ToolButton {
|
|
text: "\u2795"
|
|
onClicked: newSessionDialog.open()
|
|
ToolTip.text: "New Session"
|
|
ToolTip.visible: hovered
|
|
}
|
|
ToolButton {
|
|
text: "\u21BB"
|
|
onClicked: ollie.refreshSessions()
|
|
}
|
|
ToolButton {
|
|
text: "\u2699"
|
|
onClicked: settingsDialog.open()
|
|
ToolTip.text: "Settings"
|
|
ToolTip.visible: hovered
|
|
}
|
|
}
|
|
}
|
|
|
|
ListView {
|
|
id: sessionList
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
model: sessionTreeModel
|
|
clip: true
|
|
focus: false
|
|
activeFocusOnTab: false
|
|
|
|
// Show placeholder when empty or disconnected
|
|
Label {
|
|
anchors.centerIn: parent
|
|
text: ollie.daemonConnected ? "(no sessions)" : "(not connected)"
|
|
color: theme.midColor
|
|
font.italic: true
|
|
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.9 : 12
|
|
visible: sessionList.count === 0 || !ollie.daemonConnected
|
|
activeFocusOnTab: false
|
|
}
|
|
|
|
delegate: ItemDelegate {
|
|
width: sessionList.width
|
|
|
|
// Guard against undefined during initial render
|
|
height: {
|
|
if (!type) return 0
|
|
if (type === "header") return 24
|
|
if (type === "agent" && !parentExpanded)
|
|
return 0
|
|
return 28
|
|
}
|
|
visible: height > 0
|
|
property int modelIndex: index
|
|
property bool isHeader: type === "header"
|
|
|
|
// Headers are not interactive
|
|
enabled: !isHeader
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.LeftButton | Qt.RightButton
|
|
hoverEnabled: false
|
|
enabled: !parent.isHeader
|
|
onClicked: function(mouse) {
|
|
if (mouse.button === Qt.RightButton) {
|
|
if (type === "session") {
|
|
var item = sessionTreeModel.get(parent.modelIndex)
|
|
if (item)
|
|
root.killTargetSessionId = item.id
|
|
sessionCtxMenu.popup()
|
|
} else if (type === "agent") {
|
|
var agentItem = sessionTreeModel.get(parent.modelIndex)
|
|
if (agentItem) {
|
|
root.killTargetAgentSessionId = agentItem.parentId
|
|
root.killTargetAgentId = agentItem.agentId
|
|
}
|
|
agentCtxMenu.popup()
|
|
}
|
|
return
|
|
}
|
|
if (renamingIndex >= 0) return
|
|
if (type === "session") {
|
|
// Find first agent in this session and select it
|
|
var foundAgent = null
|
|
for (var i = 0; i < sessionTreeModel.count; ++i) {
|
|
var item = sessionTreeModel.get(i)
|
|
if (item.type === "agent" && item.parentId === id) {
|
|
foundAgent = item
|
|
break
|
|
}
|
|
}
|
|
if (foundAgent) {
|
|
// Select the first agent
|
|
activateAgent(id, foundAgent.agentId)
|
|
} else {
|
|
// No agents - just select session (clears agent)
|
|
ollie.setActiveSessionId(id)
|
|
}
|
|
toggleExpand(parent.modelIndex)
|
|
} else if (type === "agent") {
|
|
activateAgent(parentId, agentId)
|
|
}
|
|
}
|
|
onDoubleClicked: {
|
|
if (type === "session" || type === "agent") {
|
|
startRename(parent.modelIndex)
|
|
}
|
|
}
|
|
|
|
Menu {
|
|
id: sessionCtxMenu
|
|
MenuItem {
|
|
text: "New Agent..."
|
|
onClicked: {
|
|
root.newAgentTargetSessionId = root.killTargetSessionId
|
|
newAgentDialog.open()
|
|
}
|
|
}
|
|
MenuItem {
|
|
text: ollie.isSessionPaused(root.killTargetSessionId) ? "Resume" : "Pause"
|
|
onClicked: {
|
|
if (root.killTargetSessionId) {
|
|
if (ollie.isSessionPaused(root.killTargetSessionId))
|
|
ollie.resumeSession(root.killTargetSessionId)
|
|
else
|
|
ollie.pauseSession(root.killTargetSessionId)
|
|
}
|
|
}
|
|
}
|
|
MenuItem {
|
|
text: "Kill Session"
|
|
onClicked: {
|
|
if (root.killTargetSessionId)
|
|
ollie.killSession(root.killTargetSessionId)
|
|
ollie.refreshSessions()
|
|
}
|
|
}
|
|
}
|
|
|
|
Menu {
|
|
id: agentCtxMenu
|
|
MenuItem {
|
|
text: "Kill"
|
|
onClicked: {
|
|
if (root.killTargetAgentSessionId && root.killTargetAgentId)
|
|
ollie.killAgent(root.killTargetAgentSessionId, root.killTargetAgentId)
|
|
ollie.refreshSessions()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
contentItem: RowLayout {
|
|
anchors.fill: parent
|
|
spacing: 0
|
|
|
|
// Indent + expand/collapse indicator
|
|
Item {
|
|
Layout.preferredWidth: type === "header" ? 8 : (level || 0) * 16 + 16
|
|
Layout.preferredHeight: type === "header" ? 24 : 28
|
|
|
|
Label {
|
|
anchors.centerIn: parent
|
|
text: type === "session" ? (expanded ? "▼" : "▶") : ""
|
|
font.pixelSize: theme.fontSize > 0 ? theme.fontSize * 0.7 : 10
|
|
color: theme.textColor
|
|
opacity: 0.6
|
|
visible: type !== "header"
|
|
}
|
|
}
|
|
|
|
// Connection status indicator (session rows only)
|
|
Rectangle {
|
|
Layout.preferredWidth: 8
|
|
Layout.preferredHeight: 8
|
|
Layout.alignment: Qt.AlignVCenter
|
|
Layout.rightMargin: 6
|
|
radius: 4
|
|
color: connectionColor || "gray"
|
|
visible: type === "session"
|
|
}
|
|
|
|
// Name area
|
|
Item {
|
|
Layout.fillWidth: true
|
|
Layout.preferredHeight: type === "header" ? 24 : 28
|
|
|
|
TextField {
|
|
anchors.fill: parent
|
|
text: (modelIndex === renamingIndex) ? renamingText : ""
|
|
visible: modelIndex === renamingIndex
|
|
verticalAlignment: Text.AlignVCenter
|
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
|
color: theme.textColor
|
|
background: Rectangle {
|
|
color: theme.baseColor
|
|
border.color: theme.highlightColor
|
|
radius: 2
|
|
}
|
|
onTextChanged: {
|
|
if (modelIndex === renamingIndex) {
|
|
renamingText = text
|
|
}
|
|
}
|
|
Keys.onReturnPressed: commitRename()
|
|
Keys.onEscapePressed: cancelRename()
|
|
onAccepted: commitRename()
|
|
onVisibleChanged: {
|
|
if (visible) {
|
|
forceActiveFocus()
|
|
selectAll()
|
|
}
|
|
}
|
|
}
|
|
|
|
Label {
|
|
anchors.fill: parent
|
|
text: displayName || ""
|
|
color: type === "header" ? theme.midColor : theme.textColor
|
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize * (type === "header" ? 0.85 : 1) : Qt.application.font.pointSize
|
|
font.bold: type === "header"
|
|
font.capitalization: type === "header" ? Font.AllUppercase : Font.MixedCase
|
|
verticalAlignment: Text.AlignVCenter
|
|
visible: modelIndex !== renamingIndex
|
|
}
|
|
}
|
|
}
|
|
|
|
highlighted: (type === "agent" && agentId === ollie.activeAgentId && parentId === ollie.activeSessionId)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Right pane: chat + input
|
|
ColumnLayout {
|
|
spacing: 0
|
|
|
|
// Header - shows active session/agent and state
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.margins: 6
|
|
spacing: 8
|
|
|
|
// Sidebar toggle button
|
|
ToolButton {
|
|
text: root.sidebarVisible ? "◀" : "▶"
|
|
font.pixelSize: 12
|
|
implicitWidth: 24
|
|
implicitHeight: 24
|
|
onClicked: root.sidebarVisible = !root.sidebarVisible
|
|
ToolTip.text: root.sidebarVisible ? "Hide sidebar (Ctrl+B)" : "Show sidebar (Ctrl+B)"
|
|
ToolTip.visible: hovered
|
|
}
|
|
|
|
// Active session/agent label
|
|
Label {
|
|
text: {
|
|
var sessionName = ""
|
|
var agentName = ""
|
|
for (var i = 0; i < sessionTreeModel.count; ++i) {
|
|
var item = sessionTreeModel.get(i)
|
|
if (item.type === "session" && item.id === ollie.activeSessionId) {
|
|
sessionName = item.displayName || item.id
|
|
}
|
|
if (item.type === "agent" && item.agentId === ollie.activeAgentId && item.parentId === ollie.activeSessionId) {
|
|
agentName = item.displayName || item.agentId
|
|
}
|
|
}
|
|
if (sessionName && agentName) {
|
|
return sessionName + " / " + agentName
|
|
} else if (sessionName) {
|
|
return sessionName + " (no agent)"
|
|
}
|
|
return "(no session)"
|
|
}
|
|
font.bold: true
|
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize : Qt.application.font.pointSize
|
|
elide: Text.ElideMiddle
|
|
Layout.fillWidth: true
|
|
}
|
|
|
|
// State indicator
|
|
Rectangle {
|
|
width: 10; height: 10; radius: 5
|
|
color: {
|
|
if (!ollie.activeAgentId) return "#9e9e9e" // gray when no agent
|
|
if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "#9e9e9e" // gray when paused
|
|
if (ollie.activeState === "idle") return "#4caf50"
|
|
if (ollie.activeState.startsWith("calling")) return "#ff9800"
|
|
return "#2196f3"
|
|
}
|
|
}
|
|
|
|
Label {
|
|
text: {
|
|
if (!ollie.activeAgentId) return ""
|
|
if (ollie.activeSessionId && ollie.isSessionPaused(ollie.activeSessionId)) return "inactive"
|
|
return ollie.activeState
|
|
}
|
|
opacity: 0.7
|
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
|
|
}
|
|
}
|
|
|
|
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
|
|
|
|
// Chat display — block-based ListView
|
|
ScrollView {
|
|
Layout.fillWidth: true
|
|
Layout.fillHeight: true
|
|
clip: true
|
|
|
|
// Placeholder when no agent selected or disconnected
|
|
Label {
|
|
anchors.centerIn: parent
|
|
text: {
|
|
if (!ollie.daemonConnected) return "Not connected"
|
|
if (!ollie.activeSessionId) return "Select a session"
|
|
return "Select an agent to view chat"
|
|
}
|
|
color: theme.midColor
|
|
font.italic: true
|
|
font.pixelSize: theme.fontSize > 0 ? theme.fontSize : 12
|
|
visible: !ollie.activeAgentId || !ollie.daemonConnected
|
|
}
|
|
|
|
ListView {
|
|
id: chatView
|
|
model: chatModel
|
|
spacing: 4
|
|
anchors.fill: parent
|
|
anchors.margins: 4
|
|
boundsBehavior: Flickable.StopAtBounds
|
|
visible: ollie.daemonConnected && ollie.activeAgentId !== ""
|
|
|
|
// Auto-scroll when near bottom (within ~50px or at end)
|
|
// Reset to true on new blocks, user can scroll up to disable
|
|
property bool shouldAutoScroll: true
|
|
readonly property bool nearBottom: contentHeight - contentY - height < 50
|
|
|
|
onContentYChanged: {
|
|
// User scrolled up - disable auto-scroll
|
|
// User scrolled to bottom - re-enable
|
|
if (dragging || flicking) {
|
|
shouldAutoScroll = nearBottom || atYEnd
|
|
}
|
|
}
|
|
|
|
onCountChanged: {
|
|
// New block added - always scroll if we were following
|
|
if (count > 0) {
|
|
if (shouldAutoScroll)
|
|
scrollTimer.start()
|
|
}
|
|
}
|
|
|
|
Connections {
|
|
target: chatModel
|
|
function onDataChanged() {
|
|
// Content updated (streaming) - scroll if following
|
|
if (chatView.shouldAutoScroll && chatView.count > 0)
|
|
scrollTimer.start()
|
|
}
|
|
}
|
|
|
|
delegate: ColumnLayout {
|
|
id: blockItem
|
|
width: chatView.width - 8
|
|
spacing: 2
|
|
|
|
property bool blockExpanded: {
|
|
switch (blockType) {
|
|
case "user": return true
|
|
case "assistant": return true
|
|
case "tool": return true
|
|
case "info": return true
|
|
case "retry": return true
|
|
case "interrupt": return true
|
|
default: return false
|
|
}
|
|
}
|
|
|
|
// Clickable header
|
|
Rectangle {
|
|
Layout.fillWidth: true
|
|
height: 22
|
|
radius: 3
|
|
color: {
|
|
var bg = theme.baseColor
|
|
if (theme.isDark) {
|
|
switch (blockType) {
|
|
case "user": return Qt.lighter(bg, 1.8)
|
|
case "assistant": return Qt.lighter(bg, 1.5)
|
|
case "reasoning": return Qt.lighter(bg, 1.3)
|
|
case "call": return Qt.lighter(bg, 1.7)
|
|
case "tool": return Qt.lighter(bg, 1.2)
|
|
case "error": return Qt.rgba(0.6, 0.15, 0.15, 0.7)
|
|
case "info": return Qt.lighter(bg, 1.4)
|
|
case "retry": return Qt.rgba(0.6, 0.5, 0.0, 0.5)
|
|
case "interrupt": return "#3d2e00"
|
|
default: return bg
|
|
}
|
|
} else {
|
|
switch (blockType) {
|
|
case "user": return Qt.darker(bg, 1.12)
|
|
case "assistant": return Qt.darker(bg, 1.06)
|
|
case "reasoning": return Qt.darker(bg, 1.03)
|
|
case "call": return Qt.darker(bg, 1.1)
|
|
case "tool": return Qt.darker(bg, 1.04)
|
|
case "error": return Qt.rgba(1.0, 0.8, 0.8, 0.7)
|
|
case "info": return Qt.darker(bg, 1.02)
|
|
case "retry": return Qt.rgba(1.0, 0.9, 0.6, 0.5)
|
|
case "interrupt": return "#fff8e1"
|
|
default: return bg
|
|
}
|
|
}
|
|
}
|
|
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
cursorShape: Qt.PointingHandCursor
|
|
onClicked: blockItem.blockExpanded = !blockItem.blockExpanded
|
|
}
|
|
|
|
RowLayout {
|
|
anchors.fill: parent
|
|
anchors.leftMargin: 6
|
|
spacing: 4
|
|
|
|
Label {
|
|
text: blockItem.blockExpanded ? "▼" : "▶"
|
|
font.pixelSize: 8
|
|
opacity: 0.6
|
|
}
|
|
|
|
Label {
|
|
Layout.fillWidth: true
|
|
text: {
|
|
switch (blockType) {
|
|
case "user": return "\uD83E\uDDD1 You"
|
|
case "assistant": return "\uD83E\uDD16 Ollie"
|
|
case "reasoning": return "Reasoning"
|
|
case "call": return "\u25B6\uFE0F " + blockName
|
|
case "tool": return "\uD83D\uDDA5\uFE0F " + blockName
|
|
case "error": return "\u274C Error"
|
|
case "info": return "\u2139\uFE0F Info"
|
|
case "retry": return "Retry"
|
|
case "interrupt": return "\u26A0\uFE0F User Interrupt"
|
|
default: return ""
|
|
}
|
|
}
|
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize * 0.85 : 11
|
|
font.bold: true
|
|
color: theme.textColor
|
|
opacity: 0.9
|
|
}
|
|
}
|
|
}
|
|
|
|
// Block content is Markdown rendered by ChatBlockModel.
|
|
TextEdit {
|
|
id: blockContent
|
|
Layout.fillWidth: true
|
|
visible: blockItem.blockExpanded
|
|
text: blockRenderedHtml || ""
|
|
textFormat: Text.RichText
|
|
readOnly: true
|
|
selectByMouse: true
|
|
persistentSelection: true
|
|
wrapMode: TextEdit.Wrap
|
|
font.family: theme.fontFamily || Qt.application.font.family
|
|
font.pointSize: theme.fontSize > 0 ? theme.fontSize : 12
|
|
// Note: don't set 'color' here — it can override inline styles from
|
|
// syntax highlighting in Qt5. The HTML content should set colors.
|
|
|
|
// Selection colors
|
|
selectionColor: theme.highlightColor
|
|
selectedTextColor: theme.highlightedTextColor
|
|
|
|
// Restore ListView scrolling when focus is lost
|
|
onActiveFocusChanged: {
|
|
if (!activeFocus)
|
|
chatView.interactive = true
|
|
}
|
|
|
|
// Context menu for text operations
|
|
Menu {
|
|
id: textContextMenu
|
|
MenuItem {
|
|
text: "Copy"
|
|
enabled: blockContent.selectedText.length > 0
|
|
onTriggered: blockContent.copy()
|
|
}
|
|
MenuItem {
|
|
text: "Select All"
|
|
onTriggered: blockContent.selectAll()
|
|
}
|
|
}
|
|
|
|
// Mouse handling for selection (left button)
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.LeftButton
|
|
propagateComposedEvents: true
|
|
cursorShape: Qt.IBeamCursor
|
|
|
|
onPressed: function(mouse) {
|
|
chatView.interactive = false
|
|
mouse.accepted = false // Let TextEdit handle selection
|
|
}
|
|
onReleased: function(mouse) {
|
|
chatView.interactive = true
|
|
mouse.accepted = false
|
|
}
|
|
}
|
|
|
|
// Context menu (right button) - separate to avoid deselection
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.RightButton
|
|
cursorShape: Qt.IBeamCursor
|
|
|
|
onClicked: function(mouse) {
|
|
textContextMenu.popup()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
Rectangle { Layout.fillWidth: true; height: 1; color: theme.midColor }
|
|
|
|
// Input
|
|
RowLayout {
|
|
Layout.fillWidth: true
|
|
Layout.margins: 6
|
|
spacing: 4
|
|
|
|
// TextArea in ScrollView for overflow, with context menu overlay
|
|
Item {
|
|
Layout.fillWidth: true
|
|
Layout.maximumHeight: 200
|
|
Layout.preferredHeight: Math.min(promptInput.contentHeight + 16, 200)
|
|
clip: true
|
|
|
|
ScrollView {
|
|
id: promptScroll
|
|
anchors.fill: parent
|
|
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
|
|
|
|
TextArea {
|
|
id: promptInput
|
|
width: promptScroll.width
|
|
placeholderText: {
|
|
if (!ollie.daemonConnected) return "Not connected"
|
|
if (!ollie.activeSessionId) return "No session"
|
|
if (!ollie.activeAgentId) return "No agent"
|
|
if (ollie.isSessionPaused(ollie.activeSessionId)) return "Session paused"
|
|
return "Message..."
|
|
}
|
|
enabled: ollie.daemonConnected && ollie.activeSessionId !== "" && ollie.activeAgentId !== "" && !ollie.isSessionPaused(ollie.activeSessionId)
|
|
wrapMode: TextEdit.Wrap
|
|
selectByMouse: true
|
|
persistentSelection: true
|
|
|
|
property string _lastText: ""
|
|
property int _lastCursorPos: 0
|
|
|
|
onTextChanged: {
|
|
_lastText = text
|
|
_lastCursorPos = cursorPosition
|
|
}
|
|
|
|
Keys.onReturnPressed: function(event) {
|
|
if (event.modifiers & Qt.ShiftModifier) {
|
|
event.accepted = false
|
|
} else {
|
|
if (text.trim() !== "") {
|
|
ollie.submit(text.trim())
|
|
text = ""
|
|
}
|
|
event.accepted = true
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Context menu overlay - only catches right-click, on top of ScrollView
|
|
MouseArea {
|
|
anchors.fill: parent
|
|
acceptedButtons: Qt.RightButton
|
|
cursorShape: Qt.IBeamCursor
|
|
onClicked: promptContextMenu.popup()
|
|
}
|
|
|
|
Menu {
|
|
id: promptContextMenu
|
|
MenuItem {
|
|
text: "Cut"
|
|
enabled: promptInput.selectedText.length > 0
|
|
onTriggered: promptInput.cut()
|
|
}
|
|
MenuItem {
|
|
text: "Copy"
|
|
enabled: promptInput.selectedText.length > 0
|
|
onTriggered: promptInput.copy()
|
|
}
|
|
MenuItem {
|
|
text: "Paste"
|
|
enabled: promptInput.canPaste
|
|
onTriggered: promptInput.paste()
|
|
}
|
|
MenuSeparator {}
|
|
MenuItem {
|
|
text: "Select All"
|
|
onTriggered: promptInput.selectAll()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|