Author SHA1 Message Date
zach 24593472e3 chore: format python codeicons
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Successful in 30s
C++ / fmt (pull_request) Successful in 4s
C++ / clang-tidy (pull_request) Failing after 27s
C++ / build (pull_request) Failing after 40s
Rust / fmt (pull_request) Successful in 1m24s
Rust / build (pull_request) Successful in 2m12s
Rust / clippy (pull_request) Successful in 1m52s
Python / verify (pull_request) Successful in 2m24s
2026-08-31 22:34:07 +02:00
AramJonghu 0f277de635 fix: added tree-sitter dockerfile dep
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 11s
JS/TS / fmt (pull_request) Successful in 9s
JS/TS / lint (pull_request) Successful in 12s
Python / static (pull_request) Failing after 39s
C++ / clang-tidy (pull_request) Failing after 50s
Rust / fmt (pull_request) Successful in 1m59s
Rust / clippy (pull_request) Successful in 2m22s
Rust / build (pull_request) Successful in 2m32s
Python / verify (pull_request) Successful in 2m43s
2026-08-31 22:29:17 +02:00
AramJonghu 54db82f8ce fix: attempt using cmark-gfm instead of cmark
JS/TS / fmt (pull_request) Successful in 12s
JS/TS / lint (pull_request) Successful in 17s
Python / static (pull_request) Failing after 1m0s
Rust / fmt (pull_request) Successful in 1m57s
Rust / build (pull_request) Successful in 2m22s
Rust / clippy (pull_request) Successful in 2m17s
Python / verify (pull_request) Successful in 2m43s
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 18s
2026-08-31 22:22:09 +02:00
AramJonghu ff514acf54 fix(Docker): force sync db ci build
JS/TS / fmt (pull_request) Successful in 10s
JS/TS / lint (pull_request) Successful in 10s
Python / static (pull_request) Failing after 28s
Rust / fmt (pull_request) Successful in 1m19s
Rust / build (pull_request) Successful in 1m45s
Rust / clippy (pull_request) Successful in 1m42s
Python / verify (pull_request) Successful in 2m18s
C++ / fmt (pull_request) Successful in 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 12s
2026-08-31 22:05:53 +02:00
AramJonghu daf11a64c6 fix(Docker): fix ci build, added cmark dependency
JS/TS / fmt (pull_request) Successful in 15s
JS/TS / lint (pull_request) Successful in 15s
Python / static (pull_request) Failing after 34s
Rust / fmt (pull_request) Successful in 1m31s
Rust / build (pull_request) Successful in 2m2s
Rust / clippy (pull_request) Successful in 1m45s
Python / verify (pull_request) Successful in 2m21s
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 19s
2026-08-31 21:52:49 +02:00
66 changed files with 965 additions and 2830 deletions
-2
View File
@@ -18,5 +18,3 @@ dist/
network-dev/
**/zshell.build/
**/zshell.dist/
.opencode/
run-agent.sh
+20 -20
View File
@@ -26,36 +26,22 @@ if(NOT DEFINED VERSION)
endif()
endif()
if(NOT DEFINED GIT_REVISION)
execute_process(COMMAND git rev-parse HEAD
WORKING_DIRECTORY "${CMAKE_SOURCE_DIR}"
OUTPUT_VARIABLE GIT_REVISION
OUTPUT_STRIP_TRAILING_WHITESPACE
)
if("${GIT_REVISION}" STREQUAL "")
message(FATAL_ERROR "GIT_REVISION is not set and failed to get from git")
endif()
endif()
set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH})
message(STATUS "ZShell version: ${VERSION}")
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install")
set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install")
set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir")
set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir")
set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir")
set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir")
set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
set(CMAKE_INSTALL_MESSAGE NEVER)
@@ -68,7 +54,21 @@ add_compile_options(
)
if("cli" IN_LIST ENABLE_MODULES)
if("shell" IN_LIST ENABLE_MODULES)
# Build settings index
find_package(Python3 COMPONENTS Interpreter REQUIRED)
set(SETTINGS_INDEX_JSON "${CMAKE_BINARY_DIR}/settings-index.json")
execute_process(
COMMAND ${Python3_EXECUTABLE}
"${CMAKE_SOURCE_DIR}/scripts/build-settings-index.py"
"${CMAKE_SOURCE_DIR}/Modules/Settings"
"${SETTINGS_INDEX_JSON}"
RESULT_VARIABLE SETTINGS_INDEX_RESULT
)
if(NOT SETTINGS_INDEX_RESULT EQUAL 0)
message(FATAL_ERROR "Failed to build settings search index")
endif()
# Nuitka compilation
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
+2 -1
View File
@@ -6,6 +6,7 @@ Rectangle {
color: "transparent"
Behavior on color {
CAnim {}
CAnim {
}
}
}
+3 -3
View File
@@ -12,7 +12,7 @@ ScrollBar {
readonly property bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY))
readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY)
readonly property real axisLength: isHorizontal ? root.width : root.height
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
@@ -102,9 +102,9 @@ ScrollBar {
var newPos = contentPosFromThumbStart(thumbStart);
if (root.isHorizontal)
root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
root.flickable.contentX = newPos + root.flickable.originX;
else
root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
root.flickable.contentY = newPos + root.flickable.originY;
}
function visualThumbStart() {
-1
View File
@@ -12,7 +12,6 @@ TextEdit {
color: Colors.palette.m3onSurface
cursorVisible: !readOnly
font.pointSize: Tokens.font.size.small
font.family: Config.appearance.font.family.sans
renderType: TextField.NativeRendering
selectedTextColor: color
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
@@ -40,7 +40,7 @@ Item {
anchors.centerIn: parent
sourceComponent: BatteryIcon {
devState: Battery.deviceStateString.toLowerCase()
devState: Battery.deviceStateString
percentage: Battery.currentPerc
}
}
+1 -18
View File
@@ -25,8 +25,6 @@ CustomListView {
return [0];
case "variant":
return SchemeVariants.query(text);
case "files":
return Files.query(text);
default:
return Apps.search(text);
}
@@ -35,7 +33,7 @@ CustomListView {
function stateForText(text: string): string {
const prefix = Config.launcher.actionPrefix;
if (text.startsWith(prefix)) {
for (const action of ["calc", "scheme", "variant", "files"])
for (const action of ["calc", "scheme", "variant"])
if (text.startsWith(`${prefix}${action} `))
return action;
@@ -165,13 +163,6 @@ CustomListView {
PropertyChanges {
root.delegate: variantItem
}
},
State {
name: "files"
PropertyChanges {
root.delegate: filesItem
}
}
]
transitions: Transition {
@@ -273,14 +264,6 @@ CustomListView {
}
}
Component {
id: filesItem
FilesItem {
list: root
}
}
Connections {
function onTextChanged() {
root.syncDisplayText();
-2
View File
@@ -85,8 +85,6 @@ Item {
} else if (text.startsWith(Config.launcher.actionPrefix)) {
if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
currentItem.onClicked();
else if (text.startsWith(`${Config.launcher.actionPrefix}files `))
currentItem.onClicked();
else
currentItem.modelData.onClicked(list.currentList);
} else {
+1 -1
View File
@@ -16,7 +16,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight
StateLayer {
radius: Tokens.rounding.medium
radius: Tokens.rounding.small
onClicked: {
root.modelData?.onClicked(root.list);
+1 -1
View File
@@ -19,7 +19,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight
StateLayer {
radius: Tokens.rounding.medium
radius: Tokens.rounding.small
onClicked: {
Apps.launch(root.modelData);
+3 -2
View File
@@ -24,7 +24,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight
StateLayer {
radius: Tokens.rounding.medium
radius: Tokens.rounding.small
onClicked: {
root.onClicked();
@@ -97,7 +97,8 @@ Item {
text: qsTr("Open in calculator")
Behavior on opacity {
Anim {}
Anim {
}
}
}
-141
View File
@@ -1,141 +0,0 @@
import QtQuick
import Quickshell
import Quickshell.Widgets
import ZShell.Config
import qs.Components
import qs.Helpers
import qs.Modules.Launcher.Services
import qs.Services
Item {
id: root
required property var list
required property var modelData
anchors.left: parent?.left
anchors.right: parent?.right
implicitHeight: Config.launcher.sizes.itemHeight
function onClicked(): void {
root.list.visibilities.launcher = false;
if (root.modelData?.path)
Files.launch(["xdg-open", root.modelData.path]);
}
StateLayer {
id: sLayer
radius: Tokens.rounding.medium
manualHoverOverride: openLocation.hovered
onClicked: {
root.onClicked();
}
}
IconButton {
id: openLocation
anchors.right: parent.right
anchors.rightMargin: Tokens.padding.medium
anchors.verticalCenter: parent.verticalCenter
icon: "open_in_new"
isRound: true
scale: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0.4
opacity: sLayer.containsMouse || sLayer.manualHoverOverride ? 1 : 0
visible: opacity > 0
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
Behavior on scale {
Anim {
type: Anim.DefaultEffects
}
}
onClicked: {
root.list.visibilities.launcher = false;
if (root.modelData?.path)
Files.showInFolder(root.modelData.path);
}
}
Item {
anchors.fill: parent
anchors.leftMargin: Tokens.padding.small
anchors.margins: Tokens.padding.small
anchors.rightMargin: Tokens.padding.small
IconImage {
id: icon
anchors.verticalCenter: parent.verticalCenter
implicitSize: Tokens.font.size.extraLarge
source: Quickshell.iconPath(root.modelData?.icon) ?? ""
visible: !root.modelData?.isImage
}
Loader {
active: opacity > 0
anchors.verticalCenter: parent.verticalCenter
asynchronous: true
opacity: root.modelData?.isImage && image.status === Image.Loading ? 1 : 0
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
sourceComponent: LoadingIndicator {
color: Colors.palette.m3primaryContainer
implicitSize: Tokens.font.size.extraLarge
}
}
FadeImage {
id: image
property int implicitSize: Tokens.font.size.extraLarge
width: implicitSize
fillMode: Image.PreserveAspectFit
anchors.verticalCenter: parent.verticalCenter
source: root.modelData?.isImage ? root.modelData?.path ?? "" : ""
visible: root.modelData?.isImage
}
Item {
anchors.left: icon.right
anchors.leftMargin: Tokens.spacing.medium
anchors.verticalCenter: icon.verticalCenter
implicitHeight: name.implicitHeight + desc.implicitHeight
implicitWidth: parent.width - icon.width
CustomText {
id: name
font.pointSize: Tokens.font.size.normal
text: root.modelData?.name ?? ""
elide: Text.ElideRight
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
}
CustomText {
id: desc
anchors.top: name.bottom
color: Colors.palette.m3outline
elide: Text.ElideRight
font.pointSize: Tokens.font.size.small
text: root.modelData?.path ?? ""
width: root.width - icon.width - openLocation.implicitWidth - openLocation.anchors.rightMargin - Tokens.spacing.medium - Tokens.rounding.medium * 2
}
}
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight
StateLayer {
radius: Tokens.rounding.medium
radius: Tokens.rounding.small
onClicked: {
root.modelData?.onClicked(root.list);
-194
View File
@@ -1,194 +0,0 @@
// qs/Services/Files.qml
pragma Singleton
import QtQuick
import Quickshell
import Quickshell.Io
import ZShell.Config
Singleton {
id: root
property list<var> results: []
property string _pendingQuery: ""
property int _generation: 0
readonly property var _imageExts: ({
"png": true,
"jpg": true,
"jpeg": true,
"gif": true,
"bmp": true,
"webp": true,
"svg": true,
"tiff": true,
"tif": true,
"ico": true,
"heic": true,
"avif": true
})
// freedesktop mimetype-icon names; extend as needed
readonly property var _extIcons: ({
// images
"png": "image-x-generic",
"jpg": "image-x-generic",
"jpeg": "image-x-generic",
"gif": "image-x-generic",
"bmp": "image-x-generic",
"webp": "image-x-generic",
"svg": "image-x-generic",
"tiff": "image-x-generic",
"tif": "image-x-generic",
"ico": "image-x-generic",
"heic": "image-x-generic",
"avif": "image-x-generic",
// documents
"pdf": "application-pdf",
"doc": "x-office-document",
"docx": "x-office-document",
"odt": "x-office-document",
"txt": "text-x-generic",
"md": "text-x-generic",
"xls": "x-office-spreadsheet",
"xlsx": "x-office-spreadsheet",
"ods": "x-office-spreadsheet",
"ppt": "x-office-presentation",
"pptx": "x-office-presentation",
"odp": "x-office-presentation",
// archives
"zip": "package-x-generic",
"tar": "package-x-generic",
"gz": "package-x-generic",
"xz": "package-x-generic",
"7z": "package-x-generic",
"rar": "package-x-generic",
// audio/video
"mp3": "audio-x-generic",
"flac": "audio-x-generic",
"wav": "audio-x-generic",
"ogg": "audio-x-generic",
"mp4": "video-x-generic",
"mkv": "video-x-generic",
"webm": "video-x-generic",
"avi": "video-x-generic",
"mov": "video-x-generic",
// code
"js": "text-x-script",
"ts": "text-x-script",
"py": "text-x-script",
"sh": "text-x-script",
"qml": "text-x-script",
"cpp": "text-x-c++src",
"c": "text-x-csrc",
"h": "text-x-chdr",
"json": "application-json",
"html": "text-html",
"css": "text-css"
})
function showInFolder(path: string): void {
const explorer = Config.general.apps.explorer;
let args = [];
if (explorer.includes("dolphin"))
args = [...explorer, "--select", path];
else
args = [...explorer, path];
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...args]);
else
Quickshell.execDetached([...args]);
}
function launch(command: list<string>): void {
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--"].concat(command));
else
Quickshell.execDetached(command);
}
function transformSearch(search: string): string {
return search.slice(Config.launcher.actionPrefix.length + "files ".length);
}
function query(search: string): list<var> {
search = transformSearch(search);
if (search !== _pendingQuery) {
_pendingQuery = search;
debounceTimer.restart();
}
return results;
}
function _extOf(name: string): string {
const i = name.lastIndexOf(".");
// no dot, or dotfile with no real extension (".bashrc" -> treat as no ext)
if (i <= 0)
return "";
return name.slice(i + 1).toLowerCase();
}
function _iconFor(name: string, isDir: bool): string {
if (isDir)
return "folder";
const ext = _extOf(name);
return _extIcons[ext] ?? "text-x-generic";
}
function _buildEntry(path: string): var {
const name = path.split("/").pop();
const ext = _extOf(name);
// trailing slash from plocate's directory output (if -d/dir results ever appear)
const isDir = path.endsWith("/");
return {
path: path,
name: name,
icon: _iconFor(name, isDir),
isImage: !isDir && (_imageExts[ext] ?? false)
};
}
Timer {
id: debounceTimer
interval: 120
onTriggered: root._runQuery(root._pendingQuery)
}
function _runQuery(search: string): void {
_generation++;
if (!search) {
proc.running = false;
results = [];
return;
}
if (proc.running)
proc.running = false;
proc.buffer = [];
proc.myGeneration = _generation;
proc.command = ["plocate", "-i", "-b", "--limit", "50", search];
proc.running = true;
}
Process {
id: proc
property var buffer: []
property int myGeneration: 0
stdout: SplitParser {
onRead: line => proc.buffer.push(root._buildEntry(line))
}
onRunningChanged: {
if (!running && myGeneration === root._generation)
root.results = buffer;
}
}
}
+6 -6
View File
@@ -29,7 +29,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3secondary
font.bold: true
font.family: Config.appearance.font.family.clock
font.family: Appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: Time.hourStr
}
@@ -38,7 +38,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3primary
font.bold: true
font.family: Config.appearance.font.family.clock
font.family: Appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: ":"
}
@@ -47,7 +47,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3secondary
font.bold: true
font.family: Config.appearance.font.family.clock
font.family: Appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: Time.minuteStr
}
@@ -58,7 +58,7 @@ ColumnLayout {
Layout.topMargin: -Tokens.padding.large * 2
color: Colors.palette.m3tertiary
font.bold: true
font.family: Config.appearance.font.family.mono
font.family: Appearance.font.family.mono
font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale)
text: Time.format("dddd, d MMMM yyyy")
}
@@ -226,7 +226,7 @@ ColumnLayout {
anchors.right: parent.right
animateProp: "opacity"
color: Colors.palette.m3onSurfaceVariant
font.family: Config.appearance.font.family.mono
font.family: Appearance.font.family.mono
horizontalAlignment: Qt.AlignHCenter
lineHeight: 1.2
opacity: shouldBeVisible && !message.msg ? 1 : 0
@@ -295,7 +295,7 @@ ColumnLayout {
anchors.left: parent.left
anchors.right: parent.right
color: Colors.palette.m3error
font.family: Config.appearance.font.family.mono
font.family: Appearance.font.family.mono
font.pointSize: Tokens.font.size.small
horizontalAlignment: Qt.AlignHCenter
opacity: 0
+1 -2
View File
@@ -2,7 +2,6 @@ pragma ComponentBehavior: Bound
import Quickshell
import Quickshell.Wayland
import QtQuick
import ZShell.Internal
import ZShell.Config
import qs.Helpers
@@ -36,7 +35,7 @@ Scope {
}
Variants {
model: Config.general.idle.timeouts.values
model: Config.general.idle.timeouts
IdleMonitor {
required property var modelData
+1 -1
View File
@@ -41,7 +41,7 @@ Item {
anchors.centerIn: parent
animate: true
color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline
font.family: Config.appearance.font.family.mono
font.family: Appearance.font.family.mono
font.pointSize: Tokens.font.size.normal
opacity: root.buffer ? 0 : 1
text: {
+2 -2
View File
@@ -24,7 +24,7 @@ ColumnLayout {
Layout.fillWidth: true
color: Colors.palette.m3outline
elide: Text.ElideRight
font.family: Config.appearance.font.family.mono
font.family: Appearance.font.family.mono
font.weight: 500
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications")
}
@@ -66,7 +66,7 @@ ColumnLayout {
CustomText {
Layout.alignment: Qt.AlignHCenter
color: Colors.palette.m3outlineVariant
font.family: Config.appearance.font.family.mono
font.family: Appearance.font.family.mono
font.pointSize: Tokens.font.size.large
font.weight: 500
text: qsTr("No Notifications")
@@ -198,7 +198,6 @@ Item {
}
delegate: MessageDelegate {
rotation: 180
rootParent: list
}
displaced: Transition {
@@ -11,7 +11,6 @@ CustomClippingRect {
property bool expanded: false
property bool highlighted: false
property bool slim: false
required property ChatSession modelData
signal clicked(content: ChatSession)
@@ -21,9 +20,6 @@ CustomClippingRect {
implicitHeight: {
let h = 0;
if (slim)
return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
h += infoContainer.implicitHeight;
if (expanded)
@@ -89,14 +85,6 @@ CustomClippingRect {
return h;
}
opacity: root.slim ? 0 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
CustomRect {
id: title
@@ -258,7 +246,7 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small
maximumLineCount: 1
opacity: !root.expanded && !root.slim ? 1 : 0
opacity: !root.expanded ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
Behavior on y {
@@ -359,16 +347,6 @@ CustomClippingRect {
radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3)
opacity: root.slim ? 0 : 1
visible: opacity > 0
enabled: visible
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
StateLayer {
onClicked: root.expanded = !root.expanded
}
@@ -14,9 +14,7 @@ Item {
property alias list: list
property alias model: list.model
property bool highlight: false
property bool slim: false
signal requestExpand
signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest
@@ -81,7 +79,6 @@ Item {
implicitWidth: ListView.view.width
highlighted: root.highlight && ChatState.chatSession === modelData
slim: root.slim
onHighlightedChanged: if (highlighted)
list.currentIndex = index
@@ -113,15 +110,8 @@ Item {
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
function prettyModelName(): string {
if (modelsList.count < 1)
return qsTr("No models found");
const pathList = Chat.model.split("\/");
const name = pathList[pathList.length - 1];
if (!name)
return qsTr("No model selected");
return name;
}
@@ -141,7 +131,6 @@ Item {
implicitHeight: fabRoot.implicitHeight
CustomText {
id: label
text: modelsContainer.prettyModelName()
color: Colors.palette.m3onSurfaceVariant
anchors.left: parent.left
@@ -157,13 +146,10 @@ Item {
text: modelsContainer.expanded ? "unfold_less" : "unfold_more"
animate: true
font.pointSize: Tokens.font.size.large
visible: modelsList.count > 0
}
StateLayer {
id: modelLayer
enabled: modelsList.count > 0
onClicked: {
ChatState.fabExpanded = false;
modelsContainer.expanded = !modelsContainer.expanded;
@@ -260,16 +246,8 @@ Item {
anchors.margins: Tokens.padding.small
padding: 8
font.pointSize: Math.round(18 * 1.2)
icon: root.slim ? "left_panel_open" : "add"
icon: "add"
isRound: ChatState.fabExpanded
// opacity: root.slim ? 0 : 1
// visible: opacity > 0
//
// Behavior on opacity {
// Anim {
// type: Anim.DefaultEffects
// }
// }
label.transform: Rotation {
origin.y: fabRoot.label.height / 2
@@ -282,11 +260,6 @@ Item {
}
onClicked: {
if (root.slim && !ChatState.narrowSidebarExpanded) {
root.requestExpand();
return;
}
modelsContainer.expanded = false;
ChatState.fabExpanded = !ChatState.fabExpanded;
}
@@ -14,11 +14,6 @@ Singleton {
property bool inChat: false
property bool fabExpanded: false
property bool isWindow: false
property bool isWide: false
property bool settledSlim: false
property bool settledWide: false
property bool narrowSidebarExpanded: false
property bool windowIsWide: false
property ShellScreen screen
property bool sidebarVisible: Visibilities.getForActive().sidebar
@@ -9,6 +9,7 @@ TextEditBase {
color: Colors.palette.m3onSurface
readOnly: true
anchors.margins: Tokens.padding.medium
textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere
@@ -10,14 +10,11 @@ Item {
property int animOff
property Item currentItem
property bool noAnim: false
property bool loading: false
property int lastIdx: -1
readonly property Component conversationComp: ChatContent {}
signal requestClose
function loadConversation(chat): void {
if (currentItem) {
currentItem.destroy();
@@ -35,10 +32,8 @@ Item {
const attach = () => {
incubator.object.anchors.fill = container;
incubator.object.requestClose.connect(root.requestClose);
currentItem = incubator.object;
root.loading = false;
if (!noAnim)
enterAnim.start();
};
@@ -79,12 +74,6 @@ Item {
function onChatSessionChanged(): void {
exitAnim.complete();
enterAnim.complete();
if (root.noAnim) {
root.loadConversation(ChatState.chatSession);
return;
}
root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1);
root.lastIdx = ChatState.currentIdx;
exitAnim.start();
@@ -2,7 +2,6 @@ import QtQuick
import QtQuick.Layouts
import ZShell.Llm
import ZShell.Config
import qs.Modules.Notifications.Sidebar.Chat
import qs.Components
import qs.Services
@@ -17,60 +16,32 @@ Item {
required property ChatMessage message
required property ChatGeneration current
// Widest the bubble may grow to; assistant bubbles always use it so
// the markdown blocks have a deterministic width to wrap against.
readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
signal edit(text: string)
implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0)
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin
CustomClippingRect {
id: bubble
property real liveTargetWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
property real frozenWidth: 0.0
property bool layoutFrozen: false
property real layoutWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitWidth: layoutFrozen ? frozenWidth : layoutWidth
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined
layer.enabled: false
layer.smooth: true
transform: Scale {
id: stretchScale
origin.x: root.isUser ? bubble.width : 0
xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0
}
states: [
State {
name: "toSlim"
when: ChatState.isWindow && !ChatState.isWide && !ChatState.settledSlim
PropertyChanges {
bubble.layer.enabled: true
bubble.layoutFrozen: true
}
},
State {
name: "toWide"
when: ChatState.isWindow && ChatState.isWide && !ChatState.settledWide
PropertyChanges {
bubble.layer.enabled: true
bubble.layoutFrozen: true
}
}
]
onLayoutFrozenChanged: {
if (layoutFrozen)
frozenWidth = width;
}
// Behavior on implicitHeight {
// enabled: !root.segment.running
//
// Anim {
// type: Anim.DefaultEffects
// }
// }
// User messages stay a plain editable text field.
TextEditBase {
id: msgText
@@ -14,7 +14,6 @@ MouseArea {
property ChatGeneration current: modelData.activeGeneration
required property int index
readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property Item rootParent
required property ChatMessage modelData
property bool reasoningExpanded: false
readonly property var blocks: blockify(current.segments)
@@ -161,7 +160,6 @@ MouseArea {
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
spacing: Tokens.spacing.medium
Repeater {
id: segmentRep
@@ -179,8 +177,6 @@ MouseArea {
delegate: ProcessBlock {
width: root.width
blocks: root.blocks
current: root.current
rootParent: root.rootParent
onExpandedChanged: root.handleReasoningToggle(expanded)
}
@@ -1,40 +1,23 @@
import QtQuick
import QtQuick.Layouts
import ZShell.Blobs
import ZShell.Config
import ZShell.Components
import ZShell.Llm
import qs.Components
import qs.Services
Item {
CustomClippingRect {
id: root
required property int index
required property var modelData
required property var blocks
readonly property var segments: modelData.segments
required property Item rootParent
required property ChatGeneration current
readonly property bool isActive: index === blocks.length - 1
property bool expanded: false
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1]
readonly property real totalElapsedMs: root.segments.reduce((sum, s) => sum + (s.elapsedMs ?? 0), 0)
readonly property bool pendingApproval: current.toolApprovalPending && segments.filter(s => s.type === LlmSegment.Type.ToolCall).some(s => current.pendingToolCalls.includes(s))
clip: true
implicitHeight: {
const expand = expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin;
const collapsed = layout.implicitHeight;
const approval = layout.implicitHeight + approvalPrompt.implicitHeight + approvalPrompt.anchors.topMargin;
if (expanded)
return expand;
else if (pendingApproval)
return approval;
else
return collapsed;
}
implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small
Behavior on implicitHeight {
Anim {
@@ -74,7 +57,6 @@ Item {
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
anchors.centerIn: parent
opacity: root.isActive ? 0 : 1
enabled: opacity > 0
rotation: root.expanded ? 180 : 0
type: IconButton.Text
@@ -95,9 +77,6 @@ Item {
Layout.leftMargin: Tokens.spacing.medium
text: {
if (root.isActive) {
if (root.pendingApproval)
return qsTr("Waiting for approval...");
if (root.lastSegment.type === LlmSegment.Type.ToolCall)
return qsTr("Using %1...").arg(root.lastSegment.name);
return qsTr("Thinking...");
@@ -111,261 +90,6 @@ Item {
}
}
Item {
id: approvalPrompt
anchors.top: layout.bottom
anchors.topMargin: Tokens.spacing.small
anchors.left: parent.left
anchors.right: parent.right
implicitHeight: approvalLayout.implicitHeight + approvalLayout.anchors.margins * 2
visible: root.pendingApproval
CustomRect {
id: approvalBg
anchors.fill: parent
radius: Tokens.rounding.medium
color: Colors.palette.m3surface
border.width: 1
border.color: Colors.palette.m3outlineVariant
ColumnLayout {
id: approvalLayout
anchors.fill: parent
anchors.margins: Tokens.padding.medium
spacing: Tokens.spacing.small
CustomText {
id: requestHeader
text: qsTr("Tool call request")
font.pointSize: Tokens.font.size.large
color: Colors.palette.m3onSurface
}
Item {
id: toolsBg
property bool open: false
property Item rootParent: root.rootParent
property int openHeight: toolsBg.rootParent.height - Tokens.padding.medium * 2
property int openWidth: toolsBg.rootParent.width - Tokens.padding.medium * 2
implicitHeight: toolList.implicitHeight + toolList.anchors.margins * 2
Layout.fillWidth: true
function reparentWrapper(): void {
const newParent = open ? toolsBg.rootParent : toolsBg;
const pos = toolsWrapper.mapToItem(newParent, 0, 0);
toolsWrapper.parent = newParent;
toolsWrapper.x = pos.x;
toolsWrapper.y = pos.y;
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
}
BlobGroup {
id: blobGroup
color: toolsBg.open ? Colors.palette.m3surfaceContainerHighest : Colors.tPalette.m3primaryContainer
Behavior on color {
CAnim {}
}
}
MouseArea {
id: backdrop
anchors.fill: parent
enabled: false
hoverEnabled: enabled
preventStealing: true
parent: toolsBg.open ? toolsBg.rootParent : toolsBg
onClicked: toolsBg.open = false
}
Item {
id: toolsWrapper
width: toolsBg.width
height: toolBox.implicitHeight
states: State {
name: "open"
when: toolsBg.open
PropertyChanges {
backdrop.enabled: true
toolsBackground.bottomLeftRadius: Tokens.rounding.largeIncreased
toolsBackground.bottomRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topRightRadius: Tokens.rounding.largeIncreased
toolsBackground.topLeftRadius: Tokens.rounding.largeIncreased
// toolsContent.opacity: 1
toolsWrapper.height: toolsBg.openHeight
toolsWrapper.width: toolsBg.openWidth
toolsWrapper.x: (toolsBg.rootParent.width - toolsBg.openWidth) / 2
toolsWrapper.y: (toolsBg.rootParent.height - toolsBg.openHeight) / 2
elevation.opacity: 1
toolBox.opacity: 0
}
}
transitions: Transition {
id: dialogTransition
SequentialAnimation {
ScriptAction {
script: toolsBg.reparentWrapper()
}
Anim {
properties: "x,y"
}
}
PropertyAction {
property: "enabled"
}
Anim {
properties: "opacity,topLeftRadius,topRightRadius,bottomLeftRadius,bottomRightRadius"
type: Anim.DefaultEffects
}
Anim {
properties: "width,height"
}
}
Elevation {
id: elevation
anchors.fill: parent
bottomLeftRadius: toolsBackground.bottomLeftRadius
bottomRightRadius: toolsBackground.bottomRightRadius
level: 4
opacity: 0
radius: toolsBackground.radius
transform: Matrix4x4 {
matrix: toolsBackground.deformMatrix
}
}
BlobRect {
id: toolsBackground
anchors.fill: parent
bottomLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
bottomRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topRightRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
topLeftRadius: toolsBg.open ? Tokens.rounding.largeIncreased : Tokens.rounding.small
deformScale: 0
group: blobGroup
opacity: blobGroup.color.a * (root.enabled ? 1 : 0.5)
}
CustomRect {
id: toolBox
anchors.left: parent.left
anchors.right: parent.right
height: Math.min(implicitHeight, parent.height)
implicitHeight: toolList.implicitHeight + Tokens.padding.medium * 2
radius: Tokens.rounding.small
StateLayer {
onClicked: {
console.log("X, Y:\n" + toolsWrapper.x, toolsWrapper.y, "\nWidth, Height:\n" + toolsWrapper.width, toolsWrapper.height);
toolsBg.open = true;
}
}
ColumnLayout {
id: toolList
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
anchors.margins: Tokens.padding.medium
Repeater {
model: root.current.pendingToolCalls ?? []
delegate: RowLayout {
id: toolText
required property LlmSegment modelData
required property int index
spacing: Tokens.spacing.medium
function toolTarget(seg: LlmSegment): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: String(toolText.index + 1)
font.pointSize: Tokens.font.size.large
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.modelData.name)
}
MaterialIcon {
color: Colors.palette.m3onPrimaryContainer
text: "arrow_right_alt"
}
CustomText {
color: Colors.palette.m3onPrimaryContainer
text: qsTr("%1").arg(toolText.toolTarget(toolText.modelData))
}
}
}
}
}
}
}
ButtonRow {
Layout.alignment: Qt.AlignRight
spacing: Tokens.spacing.medium
IconTextButton {
icon: "check_circle"
text: qsTr("Allow")
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
isRound: true
shapeMorph: true
onClicked: root.current.approveTools()
}
IconTextButton {
icon: "cancel"
text: qsTr("Deny")
isRound: true
shapeMorph: true
inactiveColor: Colors.palette.m3error
inactiveOnColor: Colors.palette.m3onError
horizontalPadding: Tokens.padding.large
verticalPadding: Tokens.padding.small
onClicked: root.current.denyTools()
}
}
}
}
}
CustomRect {
id: expandedRect
@@ -374,6 +98,7 @@ Item {
anchors.top: layout.bottom
anchors.bottom: parent.bottom
anchors.topMargin: Tokens.spacing.small
anchors.bottomMargin: Tokens.spacing.small
color: Colors.palette.m3surfaceContainerLow
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
opacity: root.expanded ? 1 : 0
@@ -422,49 +147,6 @@ Item {
CustomText {
id: content
function toolTarget(seg: LlmSegment): string {
var a = null;
try {
a = JSON.parse(seg.arguments);
} catch (e) {}
if (a && typeof a === "object") {
const v = a.url ?? a.path;
if (v !== undefined && v !== null)
return String(v);
}
return String(seg.arguments);
}
function prettyTool(seg: LlmSegment): string {
if (seg.name === "webfetch") {
switch (seg.status) {
case LlmSegment.Status.Running:
return qsTr("Fetching");
case LlmSegment.Status.Pending:
return qsTr("Pending fetch");
case LlmSegment.Status.Success:
return qsTr("Fetched");
case LlmSegment.Status.Error:
return qsTr("Failed to fetch");
default:
return qsTr("Fetched website");
}
} else if (seg.name === "readfile") {
switch (seg.status) {
case LlmSegment.Status.Running:
return qsTr("Reading");
case LlmSegment.Status.Pending:
return qsTr("Pending read");
case LlmSegment.Status.Success:
return qsTr("Read");
case LlmSegment.Status.Error:
return qsTr("Failed to read");
default:
return qsTr("Read file");
}
}
}
anchors.left: parent.left
anchors.leftMargin: Tokens.padding.small
anchors.top: header.bottom
@@ -473,7 +155,7 @@ Item {
color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData))
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website")
}
}
}
@@ -4,67 +4,38 @@ import QtQuick
import QtQuick.Layouts
import QtQuick.Controls
import Quickshell
import ZShell.Blobs
import ZShell.Config
import ZShell.Llm
import qs.Components
import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Services
Item {
RowLayout {
id: root
property int breakpoint: Math.round(Config.sidebar.sizes.width * 2) + Tokens.spacing.medium * 2 + Tokens.padding.medium * 3
property color blobColor: Colors.tPalette.m3surfaceContainerLow
readonly property int iconSize: Math.round(Tokens.font.size.extraLarge * (96 / 72)) + Tokens.padding.large * 2
property int breakpoint: 700
property alias conversationModel: sidebar.model
property bool chatOpen: ChatState.chatSession !== null
readonly property bool isWide: root.width >= root.breakpoint
readonly property bool showList: isWide || !chatOpen
readonly property bool showContent: !isWide && chatOpen
property bool narrowShowsSidebar: true
function openConversation(conv: ChatSession, index: int): void {
}
BlobGroup {
id: blobGroup
color: root.blobColor
smoothing: Tokens.rounding.medium
}
BlobInvertedRect {
id: invertedRect
anchors.fill: parent
borderBottom: Tokens.padding.small
borderLeft: sidebar.implicitWidth + sidebar.anchors.margins + Tokens.spacing.medium
borderRight: Tokens.padding.small
borderTop: Tokens.padding.small
group: blobGroup
opacity: root.blobColor.a
radius: Tokens.rounding.largeIncreased
}
ChatList {
id: sidebar
anchors.top: parent.top
anchors.left: parent.left
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize
Layout.fillHeight: true
Layout.margins: Tokens.padding.medium
Layout.rightMargin: Tokens.spacing.medium
Layout.maximumWidth: Config.sidebar.sizes.width
Layout.preferredWidth: root.width / 4
Layout.minimumWidth: Config.sidebar.sizes.width / 2
highlight: true
slim: !root.isWide && !ChatState.narrowSidebarExpanded
z: 1
Behavior on implicitWidth {
Anim {
type: Anim.DefaultEffects
Anim {}
}
}
model: ScriptModel {
values: Chat.chats.values
}
@@ -75,78 +46,24 @@ Item {
}
onDeleteChatRequest: chat => {
if (chat === ChatState.chatSession) {
ChatState.chatSession = null;
ChatState.currentIdx = -1;
}
Chat.chats.remove(chat);
ChatState.currentIdx = -1;
ChatState.chatSession = null;
}
onNewChatRequest: {
const data = Chat.chats.insert();
ChatState.currentIdx = 0;
ChatState.chatSession = data;
}
onRequestExpand: {
ChatState.narrowSidebarExpanded = true;
}
}
CustomClippingRect {
anchors.fill: parent
anchors.leftMargin: invertedRect.borderLeft
anchors.topMargin: invertedRect.borderTop
anchors.bottomMargin: invertedRect.borderBottom
anchors.rightMargin: invertedRect.borderRight
radius: invertedRect.radius
children: [contentArea, dim]
}
CustomRect {
id: dim
anchors.fill: parent
color: Colors.palette.m3shadow
opacity: ChatState.narrowSidebarExpanded && !root.isWide ? 0.3 : 0
z: 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
}
Item {
id: contentArea
property int leftMargin: sidebar.implicitWidth
z: 0
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.margins: Tokens.padding.medium
anchors.right: parent.right
implicitWidth: root.width - leftMargin - sidebar.anchors.margins - Tokens.spacing.medium * 2 - anchors.margins * 2
states: State {
name: "wide"
when: !root.isWide
PropertyChanges {
contentArea.leftMargin: root.iconSize
}
}
transitions: Transition {
to: "wide"
Anim {
type: Anim.DefaultEffects
property: "leftMargin"
}
}
Layout.margins: Tokens.padding.extraLarge
Layout.leftMargin: Tokens.spacing.extraLarge
Layout.topMargin: Tokens.padding.large
Layout.preferredWidth: Config.sidebar.sizes.width * 2
Layout.fillWidth: true
Layout.fillHeight: true
ChatHost {
id: convHost
@@ -154,11 +71,10 @@ Item {
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width)
implicitWidth: Math.min(parent.width, 800)
clip: true
onLoadingChanged: if (!loading)
root.chatOpen = true
onImplicitWidthChanged: console.log(implicitWidth)
}
ColumnLayout {
@@ -184,51 +100,6 @@ Item {
color: Colors.tPalette.m3onSurfaceVariant
}
}
MouseArea {
id: dimArea
anchors.fill: parent
preventStealing: true
enabled: !root.isWide && ChatState.narrowSidebarExpanded
cursorShape: undefined
onClicked: {
const x = mouseX;
const margin = root.width - contentArea.implicitWidth - contentArea.anchors.margins * 2;
if (x > invertedRect.borderLeft - margin)
ChatState.narrowSidebarExpanded = false;
}
HoverHandler {
cursorShape: dimArea.enabled ? Qt.ArrowCursor : undefined
enabled: dimArea.enabled
}
}
}
Binding {
target: ChatState
property: "windowIsWide"
value: root.showList
}
Binding {
target: ChatState
property: "isWide"
value: root.isWide
}
Binding {
target: ChatState
value: contentArea.leftMargin === root.iconSize
property: "settledSlim"
}
Binding {
target: ChatState
value: contentArea.leftMargin === sidebar.implicitWidth
property: "settledWide"
}
Component {
+13 -16
View File
@@ -1,6 +1,7 @@
pragma ComponentBehavior: Bound
import ZShell.Config
import ZShell.Llm
import QtQuick
import QtQuick.Layouts
import qs.Components
@@ -30,7 +31,7 @@ Item {
Tabs {
Layout.fillWidth: true
Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight
Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight
dashState: root.props
nonAnimWidth: layout.width
visible: height > 0
@@ -52,10 +53,12 @@ Item {
x: root.props.currentTab === 0 ? 0 : -root.width
Behavior on opacity {
Anim {}
Anim {
}
}
Behavior on x {
Anim {}
Anim {
}
}
CustomRect {
@@ -70,29 +73,24 @@ Item {
}
}
Loader {
id: chatLoader
Item {
id: chatPage
anchors.bottom: parent.bottom
anchors.top: parent.top
active: Config.llm.enabled
width: parent.width
property bool tre: true
sourceComponent: Item {
id: chatPage
implicitWidth: parent.width
opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0
z: 1
Behavior on opacity {
Anim {}
Anim {
}
}
Behavior on x {
Anim {}
Anim {
}
}
CustomRect {
@@ -106,7 +104,6 @@ Item {
}
}
}
}
CustomRect {
Layout.fillWidth: true
@@ -110,10 +110,7 @@ ColumnLayout {
onClicked: {
root.visibilities.sidebar = false;
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
else
Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]);
}
}
@@ -123,10 +120,7 @@ ColumnLayout {
onClicked: {
root.visibilities.sidebar = false;
if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
else
Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]);
}
}
}
@@ -141,7 +135,8 @@ ColumnLayout {
}
}
Behavior on implicitHeight {
Anim {}
Anim {
}
}
model: FileSystemModel {
nameFilters: ["recording_*.mp4"]
@@ -167,7 +162,8 @@ ColumnLayout {
opacity: list.count === 0 ? 1 : 0
Behavior on opacity {
Anim {}
Anim {
}
}
sourceComponent: ColumnLayout {
spacing: Tokens.spacing.small
@@ -182,13 +178,16 @@ ColumnLayout {
text: "scan_delete"
Behavior on Layout.preferredHeight {
Anim {}
Anim {
}
}
Behavior on opacity {
Anim {}
Anim {
}
}
Behavior on scale {
Anim {}
Anim {
}
}
}
@@ -204,13 +203,16 @@ ColumnLayout {
text: "scan_delete"
Behavior on Layout.preferredWidth {
Anim {}
Anim {
}
}
Behavior on opacity {
Anim {}
Anim {
}
}
Behavior on scale {
Anim {}
Anim {
}
}
}
+20 -17
View File
@@ -73,21 +73,20 @@ Scope {
// mask: Region { item: inputPanel }
CustomRect {
Rectangle {
id: inputPanel
anchors.centerIn: parent
color: Colors.tPalette.m3surface
implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
implicitWidth: Math.max(layout.implicitWidth + layout.anchors.margins * 2, 450)
implicitHeight: layout.childrenRect.height + 28
implicitWidth: layout.childrenRect.width + 32
opacity: 0
radius: Tokens.rounding.small * 2
ColumnLayout {
id: layout
anchors.fill: parent
anchors.margins: Tokens.padding.medium
anchors.centerIn: parent
RowLayout {
id: contentRow
@@ -131,7 +130,7 @@ Scope {
Layout.preferredWidth: Math.min(600, contentWidth)
font.bold: true
font.pointSize: 16
text: polkitAgent.flow?.message ?? ""
text: polkitAgent.flow?.message
wrapMode: Text.WordWrap
}
@@ -148,8 +147,8 @@ Scope {
TextField {
id: passInput
Layout.fillWidth: true
Layout.preferredHeight: 40
Layout.preferredWidth: contentColumn.implicitWidth
color: Colors.palette.m3onSurfaceVariant
echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password
placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password"
@@ -169,7 +168,7 @@ Scope {
id: showPassCheckbox
Layout.alignment: Qt.AlignLeft
checked: polkitAgent.flow?.responseVisible ?? false
checked: polkitAgent.flow?.responseVisible
text: "Show Password"
onCheckedChanged: {
@@ -190,8 +189,7 @@ Scope {
clip: true
color: Colors.tPalette.m3surfaceContainerLow
implicitHeight: 0
implicitWidth: textDetailsColumn.implicitWidth + textDetailsColumn.anchors.margins * 2
radius: Tokens.rounding.medium
radius: 16
visible: true
Behavior on open {
@@ -199,8 +197,7 @@ Scope {
Anim {
property: "implicitHeight"
target: detailsPanel
to: !detailsPanel.open ? textDetailsColumn.implicitHeight + Tokens.padding.small * 2 : 0
type: Anim.DefaultEffects
to: !detailsPanel.open ? textDetailsColumn.childrenRect.height + 16 : 0
}
Anim {
@@ -208,6 +205,12 @@ Scope {
target: textDetailsColumn
to: !detailsPanel.open ? 1 : 0
}
Anim {
property: "scale"
target: textDetailsColumn
to: !detailsPanel.open ? 1 : 0.9
}
}
}
@@ -215,9 +218,10 @@ Scope {
id: textDetailsColumn
anchors.fill: parent
anchors.margins: Tokens.padding.small
anchors.margins: 8
opacity: 0
spacing: Tokens.spacing.small
scale: 0.9
spacing: 8
CustomText {
text: `actionId: ${polkitAgent.flow?.actionId}`
@@ -235,17 +239,16 @@ Scope {
Layout.preferredWidth: contentRow.implicitWidth
spacing: 8
IconButton {
IconTextButton {
id: detailsButton
Layout.alignment: Qt.AlignLeft
horizontalPadding: Tokens.padding.medium
icon: "info"
inactiveColor: Colors.palette.m3surfaceContainer
inactiveOnColor: Colors.palette.m3onSurface
isRound: true
shapeMorph: true
verticalPadding: Tokens.padding.medium
text: "Details"
onClicked: {
panelWindow.detailsOpen = !panelWindow.detailsOpen;
+7 -16
View File
@@ -30,7 +30,7 @@ ColumnLayout {
function findAnchor(item: Item, anchor: string): Item {
if (!item)
return null;
if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
if (item.settingAnchor !== undefined && item.settingAnchor === anchor)
return item;
const kids = item.children;
for (let i = 0; i < kids.length; i++) {
@@ -43,8 +43,8 @@ ColumnLayout {
function highlightAnchor(anchor: string): void {
const row = findAnchor(contentChild, anchor);
if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
row.flashHighlight(); // qmllint disable missing-property
if (row && row.flashHighlight !== undefined)
row.flashHighlight();
}
function scrollToAnchor(anchor: string): bool {
@@ -61,8 +61,8 @@ ColumnLayout {
root.animateScroll = true;
flickable.contentY = target;
Qt.callLater(() => root.animateScroll = false);
if (row.flashHighlight !== undefined) // qmllint disable missing-property
row.flashHighlight(); // qmllint disable missing-property
if (row.flashHighlight !== undefined)
row.flashHighlight();
return true;
}
@@ -154,20 +154,11 @@ ColumnLayout {
Layout.fillHeight: true
Layout.fillWidth: true
Layout.topMargin: -topMargin
bottomMargin: Tokens.padding.extraLarge
topMargin: Tokens.padding.large
fadeAmount: 0.1
contentHeight: root.contentChild?.implicitHeight ?? 0
contentItem.children: [root.contentChild]
rebound: Transition {
Anim {
properties: "x,y"
type: Anim.DefaultEffects
}
}
fadeAmount: 0.1
topMargin: Tokens.padding.large
Behavior on contentY {
enabled: root.animateScroll
+17 -10
View File
@@ -81,10 +81,14 @@ VerticalFadeFlickable {
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
RadiusBehavior on bottomLeftRadius {}
RadiusBehavior on bottomRightRadius {}
RadiusBehavior on topLeftRadius {}
RadiusBehavior on topRightRadius {}
RadiusBehavior on bottomLeftRadius {
}
RadiusBehavior on bottomRightRadius {
}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
StateLayer {
id: stateLayer
@@ -258,10 +262,14 @@ VerticalFadeFlickable {
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
width: cardList.width
RadiusBehavior on bottomLeftRadius {}
RadiusBehavior on bottomRightRadius {}
RadiusBehavior on topLeftRadius {}
RadiusBehavior on topRightRadius {}
RadiusBehavior on bottomLeftRadius {
}
RadiusBehavior on bottomRightRadius {
}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
ColumnLayout {
id: resultLayout
@@ -311,8 +319,7 @@ VerticalFadeFlickable {
z: 1
onClicked: {
const target = result.modelData.targetSubPath;
root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor);
root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor);
}
}
@@ -67,20 +67,6 @@ PageBase {
SectionHeader {
first: true
text: qsTr("General")
}
ToggleRow {
settingAnchor: "panels-sidebar-llm-enabled"
checked: Config.llm.enabled
text: qsTr("Enabled")
first: true
last: true
onToggled: Config.llm.enabled = checked
}
SectionHeader {
text: qsTr("Appearance")
}
@@ -120,7 +120,6 @@ PageBase {
NavRow {
text: qsTr("AI chat")
settingAnchor: "panels-sidebar-llm"
icon: "robot_2"
first: true
last: true
-6
View File
@@ -38,9 +38,6 @@ PageBase {
spacing: Tokens.spacing.small
IconTextButton {
property string settingAnchor: "style-wallpapers"
enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge
icon: "wallpaper"
@@ -54,9 +51,6 @@ PageBase {
}
IconTextButton {
property string settingAnchor: "style-colors-fonts"
enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge
icon: "palette"
+60 -85
View File
@@ -1,24 +1,66 @@
pragma Singleton
import "../../scripts/fzf.js" as Fzf
import "../../scripts/settings-indexer.js" as SettingsIndexer
import QtQuick
import Quickshell
import ZShell
import ZShell.Config
import qs.Paths
Singleton {
id: root
property var inverted: ({})
property var ranking: ({})
property var fzfFinder: null
readonly property var highlightCache: ({
"search": "",
"pattern": null
})
property var fzfFinder: null
readonly property string cachePath: Paths.cache + "/settings-index.json"
property var inverted: ({})
property var ranking: ({})
function highlight(text: string, search: string, colour: color): string {
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
if (search.length === 0)
return escaped;
const cache = root.highlightCache;
if (search !== cache.search) {
const tokens = tokenize(search);
cache.search = search;
if (tokens.length === 0)
cache.pattern = null;
else {
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
}
}
const pattern = cache.pattern;
if (!pattern)
return escaped;
pattern.lastIndex = 0;
if (!pattern.test(escaped))
return escaped;
pattern.lastIndex = 0;
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
}
function lookup(token: string): var {
const result = ({});
const exact = root.inverted[token] !== undefined;
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
for (const key of keys) {
const rank = root.ranking[key] ?? ({});
for (const id of root.inverted[key]) {
const w = rank[id] ?? 0.1;
if (result[id] === undefined || w > result[id])
result[id] = w;
}
}
return result;
}
function query(search: string): list<QtObject> {
const tokens = root.tokenize(search);
@@ -28,7 +70,7 @@ Singleton {
const scores = ({});
const hitCounts = ({});
for (const token of tokens) {
const matches = root.lookup(token); // { id: weight }
const matches = root.lookup(token);
for (const id in matches) {
scores[id] = (scores[id] ?? 0) + matches[id];
hitCounts[id] = (hitCounts[id] ?? 0) + 1;
@@ -61,76 +103,13 @@ Singleton {
return out;
}
function lookup(token: string): var {
const result = ({});
const exact = root.inverted[token] !== undefined;
const keys = exact ? [token] : Object.keys(root.inverted).filter(k => k.startsWith(token));
for (const key of keys) {
const rank = root.ranking[key] ?? ({});
for (const id of root.inverted[key]) {
const w = rank[id] ?? 0.1;
if (result[id] === undefined || w > result[id])
result[id] = w;
}
}
return result;
}
function tokenize(text: string): var {
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0);
}
function highlight(text: string, search: string, colour: color): string {
const escaped = text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
if (search.length === 0)
return escaped;
const cache = root.highlightCache;
if (search !== cache.search) {
const tokens = root.tokenize(search);
cache.search = search;
if (tokens.length === 0) {
cache.pattern = null;
} else {
const escapedTokens = tokens.map(t => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
cache.pattern = new RegExp("\\b(" + escapedTokens.join("|") + ")", "gi");
}
}
const pattern = cache.pattern;
if (!pattern)
return escaped;
pattern.lastIndex = 0;
if (!pattern.test(escaped))
return escaped;
pattern.lastIndex = 0;
return escaped.replace(pattern, `<font color="${colour}">$1</font>`);
}
function loadIndex(): var {
const revision = ZUtils.gitRevision();
const cached = ZUtils.readTextFile(cachePath);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (parsed.version === 3 && revision && parsed.revision === revision && parsed.locale === Qt.locale().name)
return parsed;
} catch (e) {}
}
const data = SettingsIndexer.buildIndex(`${Quickshell.shellDir}/Modules/Settings`, p => ZUtils.readTextFile(p), (d, s) => ZUtils.listFiles(d, s), (ctx, text) => qsTranslate(ctx, text));
data.revision = revision;
data.locale = Qt.locale().name;
ZUtils.writeTextFile(cachePath, JSON.stringify(data));
console.log(`SettingsSearcher: indexed ${data.entries.length} settings (revision ${revision || "unknown"})`);
return data;
}
Component.onCompleted: {
try {
const data = root.loadIndex();
const data = JSON.parse(ZUtils.settingsIndex());
entries.model = data.entries;
root.inverted = data.inverted ?? {};
root.ranking = data.ranking ?? {};
@@ -143,7 +122,6 @@ Singleton {
limit: 25
});
} catch (e) {
console.warn("SettingsSearcher: failed to build settings index:", e);
entries.model = [];
root.inverted = {};
root.ranking = {};
@@ -154,25 +132,22 @@ Singleton {
Variants {
id: entries
SettingEntry {}
SettingEntry {
}
}
component SettingEntry: QtObject {
required property var modelData
readonly property int pageIdx: modelData.pageIdx
readonly property var subPath: modelData.subPath
readonly property var targetSubPath: modelData.targetSubPath ?? []
readonly property string anchor: modelData.anchor ?? ""
readonly property var crumbIcons: modelData.crumbIcons
readonly property var crumbLabels: modelData.crumbLabels
readonly property string title: modelData.title
readonly property string section: modelData.section ?? ""
readonly property string subtext: modelData.subtext ?? ""
readonly property string anchor: modelData.anchor ?? ""
readonly property string icon: modelData.icon ?? ""
readonly property string togglePath: modelData.togglePath ?? ""
readonly property bool isToggle: togglePath.length > 0
required property var modelData
readonly property int pageIdx: modelData.pageIdx
readonly property string section: modelData.section ?? ""
readonly property var subPath: modelData.subPath
readonly property string subtext: modelData.subtext ?? ""
readonly property string title: modelData.title
readonly property string togglePath: modelData.togglePath ?? ""
readonly property bool toggleValue: {
if (!isToggle)
return false;
-23
View File
@@ -1,24 +1 @@
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
find_package(PkgConfig REQUIRED)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
if(NOT Cava_FOUND)
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
endif()
include(cmake/sensorslib.cmake)
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
qt_standard_project_setup(REQUIRES 6.9)
include(cmake/pch.cmake)
include(cmake/qml-module.cmake)
add_library(zshell-util INTERFACE)
target_include_directories(zshell-util INTERFACE "${CMAKE_CURRENT_SOURCE_DIR}/include")
add_subdirectory(ZShell)
+55 -5
View File
@@ -1,3 +1,55 @@
find_package(Qt6 REQUIRED COMPONENTS ShaderTools Core Qml Gui Quick Concurrent Sql Network DBus CanvasPainter)
find_package(PkgConfig REQUIRED)
find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
pkg_check_modules(Qalculate IMPORTED_TARGET libqalculate REQUIRED)
pkg_check_modules(Pipewire IMPORTED_TARGET libpipewire-0.3 REQUIRED)
pkg_check_modules(Aubio IMPORTED_TARGET aubio REQUIRED)
pkg_check_modules(Cava IMPORTED_TARGET libcava QUIET)
pkg_check_modules(GLIB REQUIRED glib-2.0 gobject-2.0 gio-2.0)
if(NOT Cava_FOUND)
pkg_check_modules(Cava IMPORTED_TARGET cava REQUIRED)
endif()
if(NOT TARGET Sensors::Sensors)
add_library(Sensors::Sensors UNKNOWN IMPORTED)
set_target_properties(Sensors::Sensors PROPERTIES
IMPORTED_LOCATION "${SENSORS_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
)
endif()
set(QT_QML_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/qml")
qt_standard_project_setup(REQUIRES 6.9)
function(qml_module arg_TARGET)
cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;LIBRARIES;RESOURCES")
qt_add_qml_module(${arg_TARGET}
URI ${arg_URI}
VERSION 1.0
SOURCES ${arg_SOURCES}
RESOURCES ${arg_RESOURCES}
)
qt_query_qml_module(${arg_TARGET}
URI module_uri
VERSION module_version
PLUGIN_TARGET module_plugin_target
TARGET_PATH module_target_path
QMLDIR module_qmldir
TYPEINFO module_typeinfo
)
set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
install(TARGETS ${arg_TARGET} LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
install(TARGETS "${module_plugin_target}" LIBRARY DESTINATION "${module_dir}" RUNTIME DESTINATION "${module_dir}")
install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
target_link_libraries(${arg_TARGET} PRIVATE Qt::Core Qt::Qml ${arg_LIBRARIES})
endfunction()
set_source_files_properties("${SETTINGS_INDEX_JSON}" PROPERTIES QT_RESOURCE_ALIAS "settings-index.json")
qml_module(ZShell
URI ZShell
SOURCES
@@ -8,6 +60,8 @@ qml_module(ZShell
toaster.hpp toaster.cpp
qalculator.hpp qalculator.cpp
zutils.hpp zutils.cpp
RESOURCES
"${SETTINGS_INDEX_JSON}"
LIBRARIES
Qt::Gui
Qt::Quick
@@ -15,13 +69,9 @@ qml_module(ZShell
Qt::Sql
Qt::DBus
PkgConfig::Qalculate
zshell-util
)
target_compile_definitions(ZShell PRIVATE
ZSHELL_VERSION="${VERSION}"
GIT_REVISION="${GIT_REVISION}"
)
target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}")
add_subdirectory(Models)
add_subdirectory(Internal)
-1
View File
@@ -24,7 +24,6 @@ class Llm : public ConfigObject {
CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7)
CFG_PROPERTY(bool, tools, true)
CFG_PROPERTY(bool, enabled, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance)
public:
-1
View File
@@ -288,7 +288,6 @@ qml_module(ZShell-llm
chat.hpp chat.cpp
chatstore.hpp chatstore.cpp
codehighlighter.hpp codehighlighter.cpp
filetool.hpp filetool.cpp
generation.hpp generation.cpp
llmclient.hpp llmclient.cpp
markdownblock.hpp
+1 -7
View File
@@ -1,7 +1,6 @@
#include "chat.hpp"
#include "config.hpp"
#include "filetool.hpp"
#include "llm.hpp"
#include "llmclient.hpp"
#include "webfetchtool.hpp"
@@ -18,7 +17,6 @@ Chat::Chat(QObject* parent)
m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools()));
m_client->tools()->registerTool(new FileReadTool(m_client->tools()));
const auto* llm = config::Config::instance()->llm();
m_client->setEndpoint(llm->endpoint());
@@ -40,6 +38,7 @@ Chat::Chat(QObject* parent)
});
connect(m_client, &LlmClient::busyChanged, this, [this]() {
// A fresh run supersedes the previous error.
if (m_client->busy() && !m_lastError.isEmpty()) {
m_lastError.clear();
Q_EMIT lastErrorChanged();
@@ -138,7 +137,6 @@ QString Chat::streamingChatId() const {
return m_client->streamingChatId();
}
Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) {
@@ -160,10 +158,6 @@ void Chat::refreshModels() {
m_client->refreshModels();
}
void Chat::refreshFromServer() {
m_client->refreshFromServer();
}
void Chat::selectModel(const QString& id) {
if (id.isEmpty()) return;
m_client->setModel(id);
+3 -1
View File
@@ -14,6 +14,9 @@ namespace ZShell::llm {
class LlmClient;
// QML-facing facade. Persistence lives in ChatStore, network streaming in
// LlmClient; this class only wires them together and exposes the
// application-wide state.
class Chat : public QObject {
Q_OBJECT
QML_ELEMENT
@@ -52,7 +55,6 @@ class Chat : public QObject {
Q_INVOKABLE void stop();
Q_INVOKABLE void dismissError();
Q_INVOKABLE void refreshModels();
Q_INVOKABLE void refreshFromServer();
Q_INVOKABLE void selectModel(const QString& id);
static Chat* create(QQmlEngine*, QJSEngine*);
+6 -5
View File
@@ -37,10 +37,10 @@ class CodeHighlighter : public QObject {
private:
struct State {
bool bad = false;
bool bad = false; // permanent failure, do not retry
void* lib = nullptr;
const void* lang = nullptr;
void* query = nullptr;
const void* lang = nullptr; // const TSLanguage*
void* query = nullptr; // TSQuery*
};
[[nodiscard]] static const QHash<QString, QString>& aliases();
@@ -59,14 +59,15 @@ class CodeHighlighter : public QObject {
const QVariantList& spans) const;
struct SpanCacheEntry {
QString code;
QString code; // re-compared on lookup; a hash collision can
// never deliver the wrong spans
QVariantList spans;
};
mutable QHash<QString, std::shared_ptr<const State>> m_states;
mutable QMutex m_stateMutex;
mutable QHash<QString, SpanCacheEntry> m_spanCache;
mutable QStringList m_spanCacheOrder;
mutable QStringList m_spanCacheOrder; // LRU order, oldest first
mutable int m_spanCacheBytes = 0;
mutable QMutex m_cacheMutex;
static CodeHighlighter* s_instance;
-153
View File
@@ -1,153 +0,0 @@
#include "filetool.hpp"
#include <QFile>
#include <QFileInfo>
#include <QJsonArray>
namespace ZShell::llm {
namespace {
QJsonObject makeOutput(const QString& text) {
QJsonObject obj;
obj[QStringLiteral("output")] = text;
return obj;
}
QJsonObject makeError(const QString& message) {
QJsonObject obj;
obj[QStringLiteral("error")] = message;
return obj;
}
} // namespace
FileReadTool::FileReadTool(QObject* parent) : LlmTool(parent) {}
QString FileReadTool::name() const {
return QStringLiteral("readfile");
}
QString FileReadTool::description() const {
return QStringLiteral(
"Read a text file from the local filesystem and return part of "
"its content. At most a limited number of characters is returned "
"per call; use offset and limit to page through larger files. "
"Binary files are not supported. Full output of webfetch calls "
"is stored under /tmp/zshell-llm/webfetch/ and can be read this "
"way. This tool is read-only.");
}
QJsonObject FileReadTool::parameters() const {
QJsonObject path;
path[QStringLiteral("type")] = QStringLiteral("string");
path[QStringLiteral("description")] =
QStringLiteral("Path of the file to read");
QJsonObject offset;
offset[QStringLiteral("type")] = QStringLiteral("integer");
offset[QStringLiteral("minimum")] = 0;
offset[QStringLiteral("description")] =
QStringLiteral("Byte offset to start reading from. Defaults to 0.");
QJsonObject limit;
limit[QStringLiteral("type")] = QStringLiteral("integer");
limit[QStringLiteral("minimum")] = 1;
limit[QStringLiteral("description")] = QStringLiteral(
"Maximum number of characters to return. Defaults "
"to a value that fits the model's context window.");
QJsonObject properties;
properties[QStringLiteral("path")] = path;
properties[QStringLiteral("offset")] = offset;
properties[QStringLiteral("limit")] = limit;
QJsonObject schema;
schema[QStringLiteral("type")] = QStringLiteral("object");
schema[QStringLiteral("properties")] = properties;
QJsonArray required;
required.append(QStringLiteral("path"));
schema[QStringLiteral("required")] = required;
return schema;
}
void FileReadTool::execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
Q_UNUSED(toolCallId);
const QString path = args[QStringLiteral("path")].toString().trimmed();
if (path.isEmpty()) {
done(makeError(QStringLiteral("Missing required argument 'path'")));
return;
}
const int offset = qMax(0, args[QStringLiteral("offset")].toInt(0));
int budget = inlineBudgetChars();
if (outputBudgetChars > 0) budget = qMin(budget, outputBudgetChars);
const int limit =
qBound(1, args[QStringLiteral("limit")].toInt(budget), budget);
QFile file(path);
if (!file.open(QIODevice::ReadOnly)) {
const QFileInfo info(path);
if (info.exists() && info.isDir()) {
done(makeError(QStringLiteral(
"Path is a directory, not a "
"file: %1")
.arg(path)));
} else {
done(makeError(QStringLiteral("Cannot open file: %1").arg(path)));
}
return;
}
const qint64 fileSize = file.size();
if (fileSize == 0) {
file.close();
done(makeOutput(QString()));
return;
}
if (offset >= fileSize) {
file.close();
done(makeError(QStringLiteral(
"Offset %1 is beyond the end of the "
"file (%2 bytes)")
.arg(offset)
.arg(fileSize)));
return;
}
if (!file.seek(offset)) {
file.close();
done(makeError(QStringLiteral("Cannot seek in file: %1").arg(path)));
return;
}
const qint64 wanted = qint64(limit) * 4 + 16;
const QByteArray chunk = file.read(qMin(wanted, fileSize - offset));
file.close();
if (chunk.contains('\0')) {
done(makeError(
QStringLiteral("Binary files are not supported: %1").arg(path)));
return;
}
const QString content = QString::fromUtf8(chunk);
if (content.size() <= limit) {
done(makeOutput(content));
return;
}
const QString head = content.left(limit);
const int nextOffset = offset + head.toUtf8().size();
const QString output =
head +
QStringLiteral(
"\n\n[... truncated: showing %1 characters (bytes %2-%3 of a "
"%4 byte file). Continue with offset=%5.]")
.arg(limit)
.arg(offset)
.arg(nextOffset)
.arg(fileSize)
.arg(nextOffset);
done(makeOutput(output));
}
} // namespace ZShell::llm
-23
View File
@@ -1,23 +0,0 @@
#pragma once
#include "tool.hpp"
namespace ZShell::llm {
class FileReadTool : public LlmTool {
Q_OBJECT
public:
explicit FileReadTool(QObject* parent = nullptr);
QString name() const override;
QString description() const override;
QJsonObject parameters() const override;
void execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
};
} // namespace ZShell::llm
+2 -45
View File
@@ -1,9 +1,5 @@
#include "generation.hpp"
#include "llmclient.hpp"
#include "messagemodel.hpp"
#include "session.hpp"
#include <QDateTime>
namespace ZShell::llm {
@@ -50,7 +46,7 @@ QString ChatGeneration::reasoning() const {
bool ChatGeneration::reasoningActive() const {
if (!m_streaming) return false;
if (!content().isEmpty()) return false;
return !hasRunningTool() && !hasPendingTool();
return !hasRunningTool();
}
qint64 ChatGeneration::reasoningElapsedMs() const {
@@ -91,45 +87,6 @@ bool ChatGeneration::hasRunningTool() const {
return false;
}
bool ChatGeneration::hasPendingTool() const {
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
return true;
return false;
}
void ChatGeneration::setApprovalPending(bool value) {
if (m_approvalPending == value) return;
m_approvalPending = value;
Q_EMIT toolApprovalPendingChanged();
}
QVariantList ChatGeneration::pendingToolCalls() const {
QVariantList out;
if (!m_approvalPending) return out;
for (const auto* segment : m_segments)
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
out.append(QVariant::fromValue(segment));
return out;
}
void ChatGeneration::approveTools() {
if (auto* llmClient = client()) llmClient->approveTools();
}
void ChatGeneration::denyTools() {
if (auto* llmClient = client()) llmClient->denyTools();
}
LlmClient* ChatGeneration::client() const {
if (auto* message = qobject_cast<ChatMessage*>(parent()))
if (auto* model = qobject_cast<ChatMessageModel*>(message->parent()))
if (auto* session = model->session()) return session->client();
return nullptr;
}
void ChatGeneration::updateReasoningActive() {
const bool active = reasoningActive();
if (m_reasoningActive == active) return;
@@ -220,7 +177,7 @@ LlmSegment* ChatGeneration::beginToolCall(
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
segment->setName(name);
segment->setToolCallId(toolCallId);
segment->setStatus(LlmSegment::Status::Pending);
segment->setStatus(LlmSegment::Status::Running);
segment->begin();
addSegment(segment);
Q_EMIT toolStateChanged();
-20
View File
@@ -6,13 +6,10 @@
#include <QObject>
#include <QString>
#include <QTimer>
#include <QVariantList>
#include <QtQml>
namespace ZShell::llm {
class LlmClient;
class ChatGeneration : public QObject {
Q_OBJECT
QML_ELEMENT
@@ -36,12 +33,6 @@ class ChatGeneration : public QObject {
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount NOTIFY segmentsChanged)
Q_PROPERTY(
bool toolApprovalPending READ toolApprovalPending NOTIFY
toolApprovalPendingChanged)
Q_PROPERTY(
QVariantList pendingToolCalls READ pendingToolCalls NOTIFY
toolApprovalPendingChanged)
public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
@@ -57,14 +48,6 @@ class ChatGeneration : public QObject {
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
[[nodiscard]] int toolCallCount() const;
[[nodiscard]] bool hasRunningTool() const;
[[nodiscard]] bool hasPendingTool() const;
[[nodiscard]] bool toolApprovalPending() const { return m_approvalPending; }
void setApprovalPending(bool value);
[[nodiscard]] QVariantList pendingToolCalls() const;
Q_INVOKABLE void approveTools();
Q_INVOKABLE void denyTools();
void setContent(const QString& value);
void appendContent(const QString& piece);
@@ -86,17 +69,14 @@ class ChatGeneration : public QObject {
void streamingChanged();
void toolStateChanged();
void segmentsChanged();
void toolApprovalPendingChanged();
private:
void updateReasoningActive();
LlmClient* client() const;
QTimer m_timer;
QList<LlmSegment*> m_segments;
bool m_reasoningActive = false;
bool m_streaming = false;
bool m_approvalPending = false;
qint64 m_timestamp;
};
+44 -347
View File
@@ -12,117 +12,11 @@
#include <QJsonObject>
#include <QNetworkReply>
#include <QNetworkRequest>
#include <QRegularExpression>
#include <QSet>
#include <QUrl>
#include <memory>
namespace ZShell::llm {
namespace {
constexpr int kMinContextTokens = 512;
qsizetype jsonValueChars(const QJsonValue& value) {
switch (value.type()) {
case QJsonValue::String:
return value.toString().size();
case QJsonValue::Array: {
qsizetype total = 0;
for (const QJsonValue& item : value.toArray())
total += jsonValueChars(item);
return total;
}
case QJsonValue::Object: {
qsizetype total = 0;
const QJsonObject obj = value.toObject();
for (auto it = obj.constBegin(); it != obj.constEnd(); ++it)
total += jsonValueChars(it.value());
return total;
}
default:
return 2;
}
}
int estimateMessageTokens(const QJsonObject& message) {
return int(jsonValueChars(message) / LlmTool::CharsPerToken) + 4;
}
struct ContextUnit {
QList<QJsonObject> messages;
int tokens = 0;
};
void shrinkUnitToBudget(QList<QJsonObject>& unit, int budgetTokens) {
static const QStringList kTextKeys = {
QStringLiteral("content"), QStringLiteral("reasoning_content")};
for (int pass = 0; pass < 8; ++pass) {
int tokens = 0;
for (const QJsonObject& message : unit)
tokens += estimateMessageTokens(message);
if (tokens <= budgetTokens) return;
qsizetype bestMessage = -1;
QString bestKey;
qsizetype bestLength = 0;
for (qsizetype i = 0; i < unit.size(); ++i) {
const QJsonObject message = unit.at(i);
for (const QString& key : kTextKeys) {
const QJsonValue value = message.value(key);
if (value.isString() && value.toString().size() > bestLength) {
bestMessage = i;
bestKey = key;
bestLength = value.toString().size();
}
}
}
if (bestMessage < 0) return;
QString text = unit.at(bestMessage).value(bestKey).toString();
text.truncate(qMax<qsizetype>(32, text.size() * 3 / 4));
const QString marker = QStringLiteral("\n[…]");
if (!text.endsWith(marker)) text += marker;
unit[bestMessage][bestKey] = text;
}
}
int estimateArrayTokens(const QJsonArray& messages) {
int total = 0;
for (const QJsonValue& value : messages)
total += estimateMessageTokens(value.toObject());
return total;
}
constexpr int kToolResultStubChars = 512;
constexpr double kPromptEstimateDiscount = 10.0 / 12.0;
constexpr int kMinToolResultTokens = 128;
int completionReserve(int contextSize) {
return qBound(2048, contextSize / 8, 8192);
}
int promptBudgetTokens(int contextSize) {
return int(
(contextSize - completionReserve(contextSize)) *
kPromptEstimateDiscount);
}
constexpr int kRefreshIntervalMs = 60'000;
constexpr int kProbeTimeoutMs = 2000;
int contextSizeFromErrorMessage(const QString& message) {
static const QRegularExpression re(
QLatin1String("available context size \\((\\d+)"));
const auto match = re.match(message);
if (match.hasMatch()) return match.captured(1).toInt();
return 0;
}
} // namespace
QString LlmClient::completionsPath(
const QString& endpoint, const QString& subpath) {
QString base = endpoint.trimmed();
@@ -171,12 +65,7 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
&ToolRegistry::enabledChanged,
this,
&LlmClient::toolsEnabledChanged);
m_refreshTimer.setParent(this);
m_refreshTimer.setInterval(kRefreshIntervalMs);
connect(
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer);
m_refreshTimer.start();
probeContextSize();
}
LlmClient::~LlmClient() {
@@ -189,8 +78,8 @@ void LlmClient::setEndpoint(const QString& value) {
if (m_endpoint == value) return;
m_endpoint = value;
Q_EMIT endpointChanged();
m_refreshTimer.start();
refreshFromServer();
probeContextSize();
if (m_model.isEmpty()) refreshModels();
}
void LlmClient::setModel(const QString& value) {
@@ -206,11 +95,9 @@ void LlmClient::setTemperature(double value) {
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target) return;
refreshModels();
m_contextRetryUsed = false;
m_active = session;
m_streaming = target;
target->setStreaming(true);
m_streaming->setStreaming(true);
setBusy(true);
setStreamingChatId(session->id());
@@ -238,17 +125,12 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
m_round = 0;
m_contentMark = 0;
m_reasoningMark = 0;
setApprovalPending(false);
probeContextSize([this, session, target]() {
if (m_active != session || m_streaming != target) return;
sendRound();
});
}
void LlmClient::sendRound() {
if (!m_active || !m_streaming) return;
probeContextSize();
m_finishReason.clear();
m_callBuilders.clear();
m_callResults.clear();
@@ -264,11 +146,9 @@ void LlmClient::sendRound() {
return;
}
ChatSession* active = m_active;
ChatGeneration* streaming = m_streaming;
const auto* model = active->messagesModel();
const auto* model = m_active->messagesModel();
const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(streaming->parent()));
model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent()));
if (targetRow < 0) {
fail(QStringLiteral(
"Internal error: generation target is not in the "
@@ -276,38 +156,10 @@ void LlmClient::sendRound() {
return;
}
int transcriptTokens = 0;
for (const QJsonValue& value : m_transcript)
transcriptTokens += estimateMessageTokens(value.toObject());
QJsonArray messages =
buildContextMessages(m_active, targetRow, transcriptTokens);
QJsonArray messages = buildContextMessages(m_active, targetRow);
for (const QJsonValue& value : m_transcript)
messages.append(value);
if (m_contextSize > 0) {
const int promptLimit = promptBudgetTokens(m_contextSize);
while (estimateArrayTokens(messages) > promptLimit) {
bool stubbed = false;
for (qsizetype i = 0; i < messages.size(); ++i) {
QJsonObject message = messages.at(i).toObject();
if (message.value("role").toString() != QLatin1String("tool"))
continue;
const QString content = message.value("content").toString();
if (content.size() <= kToolResultStubChars) continue;
QString stub = content.left(kToolResultStubChars);
stub += QStringLiteral(
"\n[truncated to fit the context window; was "
"%1 characters]")
.arg(content.size());
message["content"] = stub;
messages[i] = message;
stubbed = true;
break;
}
if (!stubbed) break;
}
}
QJsonObject body;
QJsonObject streamOptions;
streamOptions[QStringLiteral("include_usage")] = true;
@@ -347,40 +199,28 @@ void LlmClient::sendRound() {
roundFinished();
else if (error == QNetworkReply::OperationCanceledError)
finishTurn();
else {
const QString message =
serverErrorMessage(responseBody, errorString);
const int reported = contextSizeFromErrorMessage(message);
if (reported > 0 && reported != m_contextSize &&
!m_contextRetryUsed) {
qWarning() << "LlmClient: prompt overflow, server reports"
<< reported << "tokens; retrying round";
setContextSize(reported);
m_contextRetryUsed = true;
sendRound();
return;
}
fail(message);
}
else
fail(serverErrorMessage(responseBody, errorString));
});
}
QJsonArray LlmClient::buildContextMessages(
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const {
ChatSession* session, int stopBeforeRow) const {
const auto* model = session->messagesModel();
QList<ContextUnit> units;
QJsonArray messages;
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
const auto* message = model->at(row);
const auto* generation = message->activeGeneration();
if (!generation) continue;
ContextUnit unit;
if (message->role() == ChatMessage::Role::User) {
QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = generation->content();
unit.messages.append(user);
} else {
messages.append(user);
continue;
}
QList<const LlmSegment*> toolSegments;
for (const auto* segment : generation->segments())
if (segment->type() == LlmSegment::Type::ToolCall)
@@ -395,13 +235,16 @@ QJsonArray LlmClient::buildContextMessages(
if (!generation->reasoning().isEmpty())
assistant[QStringLiteral("reasoning_content")] =
generation->reasoning();
if (!toolSegments.isEmpty()) {
if (toolSegments.isEmpty()) {
messages.append(assistant);
continue;
}
QJsonArray calls;
for (const auto* segment : toolSegments) {
QJsonObject function;
function[QStringLiteral("name")] = segment->name();
function[QStringLiteral("arguments")] =
segment->arguments();
function[QStringLiteral("arguments")] = segment->arguments();
QJsonObject call;
call[QStringLiteral("id")] = segment->toolCallId();
call[QStringLiteral("type")] = QStringLiteral("function");
@@ -409,55 +252,20 @@ QJsonArray LlmClient::buildContextMessages(
calls.append(call);
}
assistant[QStringLiteral("tool_calls")] = calls;
}
unit.messages.append(assistant);
messages.append(assistant);
for (const auto* segment : toolSegments) {
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] =
segment->toolCallId();
toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId();
toolMessage[QStringLiteral("content")] = segment->result();
unit.messages.append(toolMessage);
messages.append(toolMessage);
}
}
for (const QJsonObject& messageJson : unit.messages)
unit.tokens += estimateMessageTokens(messageJson);
units.append(unit);
}
if (m_contextSize > 0) {
int budget = m_contextSize - completionReserve(m_contextSize) -
extraReserveTokens;
budget = qMax(budget, kMinContextTokens);
int total = 0;
for (const ContextUnit& unit : units)
total += unit.tokens;
while (units.size() > 1 && total > budget) {
total -= units.first().tokens;
units.removeFirst();
}
while (
units.size() > 1 &&
units.first().messages.first()[QStringLiteral("role")].toString() ==
QLatin1String("assistant")) {
total -= units.first().tokens;
units.removeFirst();
}
if (units.size() == 1 && units.first().tokens > budget)
shrinkUnitToBudget(units.first().messages, budget);
}
QJsonArray messages;
for (const ContextUnit& unit : units)
for (const QJsonObject& messageJson : unit.messages)
messages.append(messageJson);
return messages;
}
void LlmClient::applyToolCallDelta(const QJsonObject& call) {
ChatGeneration* streaming = m_streaming;
if (!streaming) return;
if (!m_streaming) return;
const int index = call[QStringLiteral("index")].toInt(-1);
if (index < 0) return;
while (m_callBuilders.size() <= index)
@@ -473,8 +281,8 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) {
if (!arguments.isEmpty()) builder.arguments += arguments;
if (!builder.segment) {
streaming->closeOpenSegments();
builder.segment = streaming->beginToolCall(builder.name, builder.id);
m_streaming->closeOpenSegments();
builder.segment = m_streaming->beginToolCall(builder.name, builder.id);
}
builder.segment->setName(builder.name);
builder.segment->setToolCallId(builder.id);
@@ -483,27 +291,12 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) {
void LlmClient::roundFinished() {
if (m_roundDone || !m_streaming) return;
ChatGeneration* streaming = m_streaming;
m_roundDone = true;
bool hasCalls = false;
for (const auto& builder : m_callBuilders)
if (builder.seen) hasCalls = true;
if (!hasCalls) {
finishTurn();
return;
}
if (m_finishReason != QLatin1String("tool_calls")) {
qWarning() << "LlmClient: stream ended" << m_finishReason
<< "with incomplete tool calls, ending turn";
for (const auto& builder : m_callBuilders) {
if (!builder.seen || !builder.segment) continue;
builder.segment->finishTool(
QStringLiteral(
"Stream truncated before the tool call "
"completed"),
false);
}
if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) {
finishTurn();
return;
}
@@ -513,10 +306,10 @@ void LlmClient::roundFinished() {
return;
}
streaming->closeOpenSegments();
m_streaming->closeOpenSegments();
const QString content = streaming->content();
const QString reasoning = streaming->reasoning();
const QString content = m_streaming->content();
const QString reasoning = m_streaming->reasoning();
QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (content.size() > m_contentMark) {
@@ -544,7 +337,7 @@ void LlmClient::roundFinished() {
m_transcript.append(assistant);
m_round++;
setApprovalPending(true);
executeAllCalls();
}
void LlmClient::executeAllCalls() {
@@ -552,18 +345,6 @@ void LlmClient::executeAllCalls() {
m_pendingCalls = 0;
m_callResults = QList<ToolCallResult>(m_callBuilders.size());
int batchSize = 0;
for (const auto& call : m_callBuilders)
if (call.seen) ++batchSize;
int perCallChars = 0;
if (batchSize > 0 && m_contextSize > 0) {
const int available = promptBudgetTokens(m_contextSize) -
estimateArrayTokens(m_transcript);
const int perCallTokens =
qMax(available / batchSize, kMinToolResultTokens);
perCallChars = perCallTokens * LlmTool::CharsPerToken;
}
for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i);
if (!call.seen) continue;
@@ -594,11 +375,7 @@ void LlmClient::executeAllCalls() {
}
++m_pendingCalls;
tool->execute(
call.id,
args,
perCallChars,
[this, i, call](const QJsonObject& result) {
tool->execute(args, [this, i, call](const QJsonObject& result) {
if (!m_streaming) return;
const bool success = result.contains(QStringLiteral("output"));
const QString content =
@@ -630,56 +407,12 @@ void LlmClient::flushCallResults() {
sendRound();
}
void LlmClient::setApprovalPending(bool value) {
if (m_approvalPending == value) return;
m_approvalPending = value;
if (ChatGeneration* streaming = m_streaming)
streaming->setApprovalPending(value);
}
void LlmClient::approveTools() {
if (!m_approvalPending) return;
ChatGeneration* streaming = m_streaming;
if (!streaming) return;
setApprovalPending(false);
for (auto* segment : streaming->segments()) {
if (segment->type() != LlmSegment::Type::ToolCall ||
segment->status() != LlmSegment::Status::Pending)
continue;
segment->setStatus(LlmSegment::Status::Running);
segment->begin();
}
executeAllCalls();
}
void LlmClient::denyTools() {
if (!m_approvalPending || !m_streaming) return;
setApprovalPending(false);
finishPendingCalls(QStringLiteral("Denied by user"));
finishTurn();
}
void LlmClient::finishPendingCalls(const QString& resultText) {
ChatGeneration* streaming = m_streaming;
if (!streaming) return;
for (auto* segment : streaming->segments())
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
segment->finishTool(resultText, false);
}
void LlmClient::stop() {
if (!m_busy) return;
if (m_approvalPending) {
setApprovalPending(false);
finishPendingCalls(QStringLiteral("Cancelled"));
finishTurn();
return;
}
if (m_toolPhase) {
m_tools->cancelAll();
if (ChatGeneration* streaming = m_streaming) {
for (auto* segment : streaming->segments()) {
if (m_streaming) {
for (auto* segment : m_streaming->segments()) {
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->running())
segment->finishTool(QStringLiteral("Cancelled"), false);
@@ -688,25 +421,16 @@ void LlmClient::stop() {
finishTurn();
return;
}
if (m_reply)
m_reply->abort();
else
endStream();
if (m_reply) m_reply->abort();
}
void LlmClient::endStream() {
if (!m_streaming) return;
ChatGeneration* generation = m_streaming;
ChatSession* session = m_active;
auto* generation = m_streaming;
auto* session = m_active;
m_streaming = nullptr;
m_active = nullptr;
generation->setStreaming(false);
m_approvalPending = false;
generation->setApprovalPending(false);
for (auto* segment : generation->segments())
if (segment->type() == LlmSegment::Type::ToolCall &&
segment->status() == LlmSegment::Status::Pending)
segment->finishTool(QStringLiteral("Cancelled"), false);
if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->toolCallCount() == 0) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
@@ -730,7 +454,6 @@ void LlmClient::finishTurn() {
m_pendingClear.clear();
}
if (session) session->persist();
refreshFromServer();
}
void LlmClient::clearOnFinish(ChatSession* session) {
@@ -805,12 +528,6 @@ void LlmClient::handleLine(const QByteArray& line) {
}
}
void LlmClient::refreshFromServer() {
if (m_endpoint.trimmed().isEmpty()) return;
probeContextSize();
refreshModels();
}
void LlmClient::refreshModels() {
const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
@@ -846,9 +563,6 @@ void LlmClient::refreshModels() {
if (m_model.isEmpty()) {
m_model = models.first();
Q_EMIT modelChanged();
} else if (!models.contains(m_model)) {
m_model = models.first();
Q_EMIT modelChanged();
}
});
}
@@ -856,26 +570,18 @@ void LlmClient::refreshModels() {
void LlmClient::setContextSize(int size) {
if (size <= 0 || m_contextSize == size) return;
m_contextSize = size;
m_tools->setContextSize(size);
Q_EMIT contextSizeChanged();
}
void LlmClient::probeContextSize(std::function<void()> done) {
void LlmClient::probeContextSize() {
QString base = m_endpoint.trimmed();
while (base.endsWith('/'))
base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) {
if (done) done();
return;
}
if (!url.isValid() || url.host().isEmpty()) return;
auto* reply = m_manager.get(QNetworkRequest(url));
auto settled = std::make_shared<bool>(false);
const auto finish = [this, reply, settled, done]() {
if (*settled) return;
*settled = true;
connect(reply, &QNetworkReply::finished, this, [this, reply]() {
const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll();
reply->deleteLater();
@@ -900,26 +606,17 @@ void LlmClient::probeContextSize(std::function<void()> done) {
.toInt(0);
}
}
if (size > 0)
setContextSize(size);
else if (m_contextSize <= 0)
setContextSize(4096);
if (done) done();
};
connect(reply, &QNetworkReply::finished, this, finish);
QTimer::singleShot(kProbeTimeoutMs, this, [reply, settled]() {
if (!*settled) reply->abort();
setContextSize(size > 0 ? size : 4096);
});
}
void LlmClient::updateTokenUsage(const QJsonObject& data) {
ChatSession* active = m_active;
if (!active || m_contextSize <= 0) return;
if (!m_active || m_contextSize <= 0) return;
const QJsonObject usage = data["usage"].toObject();
if (usage.isEmpty()) return;
const double used = usage.value("prompt_tokens").toDouble() +
usage.value("completion_tokens").toDouble();
if (used > 0) active->setLastTokenCount(static_cast<int>(used));
if (used > 0) m_active->setLastTokenCount(static_cast<int>(used));
}
void LlmClient::shortRequest(
+4 -14
View File
@@ -10,7 +10,6 @@
#include <QPointer>
#include <QString>
#include <QStringList>
#include <QTimer>
#include <functional>
@@ -43,8 +42,7 @@ class LlmClient : public QObject {
void setModel(const QString& value);
void setTemperature(double value);
void setContextSize(int size);
void probeContextSize(std::function<void()> done = {});
Q_INVOKABLE void refreshFromServer();
void probeContextSize();
[[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
@@ -58,9 +56,6 @@ class LlmClient : public QObject {
void clearOnFinish(ChatSession* session);
void sessionRemoved(ChatSession* session);
void approveTools();
void denyTools();
void refreshModels();
void requestTitle(ChatSession* session, const QString& userText);
void requestIcon(ChatSession* session, const QString& userText);
@@ -88,13 +83,11 @@ class LlmClient : public QObject {
void sendRound();
QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
ChatSession* session, int stopBeforeRow) const;
void applyToolCallDelta(const QJsonObject& call);
void roundFinished();
void executeAllCalls();
void flushCallResults();
void setApprovalPending(bool value);
void finishPendingCalls(const QString& resultText);
void finishTurn();
void fail(const QString& message);
void handleLine(const QByteArray& line);
@@ -116,9 +109,8 @@ class LlmClient : public QObject {
ToolRegistry* m_tools = nullptr;
QNetworkReply* m_reply = nullptr;
QByteArray m_buffer;
QTimer m_refreshTimer;
QPointer<ChatSession> m_active;
QPointer<ChatGeneration> m_streaming;
ChatSession* m_active = nullptr;
ChatGeneration* m_streaming = nullptr;
QPointer<ChatSession> m_pendingClear;
bool m_busy = false;
QString m_streamingChatId;
@@ -141,8 +133,6 @@ class LlmClient : public QObject {
qsizetype m_reasoningMark = 0;
bool m_roundDone = false;
bool m_toolPhase = false;
bool m_approvalPending = false;
bool m_contextRetryUsed = false;
int m_pendingCalls = 0;
static constexpr int kMaxToolRounds = 12;
};
+1 -1
View File
@@ -29,7 +29,7 @@ class LlmSegment : public QObject {
enum class Type : int { Reasoning = 0, ToolCall, Content };
Q_ENUM(Type)
enum class Status : int { None = 0, Running, Success, Error, Pending };
enum class Status : int { None = 0, Running, Success, Error };
Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
-20
View File
@@ -6,18 +6,6 @@ LlmTool::LlmTool(QObject* parent) : QObject(parent) {}
LlmTool::~LlmTool() = default;
void LlmTool::setContextSize(int value) {
if (m_contextSize == value) return;
m_contextSize = value;
}
int LlmTool::inlineBudgetChars() const {
if (m_contextSize <= 0) return DefaultInlineChars;
const int tokens =
qBound(512, m_contextSize / 4, DefaultInlineChars / CharsPerToken);
return tokens * CharsPerToken;
}
void LlmTool::cancel() {}
QJsonObject LlmTool::specification() const {
@@ -42,7 +30,6 @@ void ToolRegistry::setEnabled(bool value) {
void ToolRegistry::registerTool(LlmTool* tool) {
if (!tool || m_tools.contains(tool)) return;
tool->setParent(this);
tool->setContextSize(m_contextSize);
m_tools.append(tool);
}
@@ -65,11 +52,4 @@ void ToolRegistry::cancelAll() {
tool->cancel();
}
void ToolRegistry::setContextSize(int value) {
if (m_contextSize == value) return;
m_contextSize = value;
for (auto* tool : m_tools)
tool->setContextSize(value);
}
} // namespace ZShell::llm
-16
View File
@@ -22,24 +22,11 @@ class LlmTool : public QObject {
[[nodiscard]] virtual QJsonObject parameters() const = 0;
virtual void execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) = 0;
virtual void cancel();
[[nodiscard]] QJsonObject specification() const;
[[nodiscard]] int contextSize() const { return m_contextSize; }
void setContextSize(int value);
[[nodiscard]] int inlineBudgetChars() const;
static constexpr int CharsPerToken = 4;
static constexpr int DefaultInlineChars = 64 * 1024;
protected:
int m_contextSize = 0;
};
class ToolRegistry : public QObject {
@@ -58,14 +45,11 @@ class ToolRegistry : public QObject {
[[nodiscard]] QJsonArray specifications() const;
void cancelAll();
void setContextSize(int value);
Q_SIGNALS:
void enabledChanged();
private:
bool m_enabled = true;
int m_contextSize = 0;
QList<LlmTool*> m_tools;
};
+6 -69
View File
@@ -1,7 +1,5 @@
#include "webfetchtool.hpp"
#include <QDir>
#include <QFile>
#include <QJsonArray>
#include <QNetworkReply>
#include <QNetworkRequest>
@@ -10,9 +8,6 @@
namespace ZShell::llm {
const QString WebFetchTool::StoragePath =
QStringLiteral("/tmp/zshell-llm/webfetch");
namespace {
const char* kUserAgent =
@@ -51,10 +46,7 @@ QString WebFetchTool::description() const {
return QStringLiteral(
"Fetch content from an HTTP or HTTPS URL and return it as plain "
"text or raw HTML. HTML pages are reduced to their visible text "
"by default. Only a limited amount of content is returned inline; "
"the full output of every call is saved under "
"/tmp/zshell-llm/webfetch/, where it can be paged through with "
"the readfile tool. This tool is read-only.");
"by default. This tool is read-only.");
}
QJsonObject WebFetchTool::parameters() const {
@@ -110,14 +102,9 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) {
}
void WebFetchTool::execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
const QJsonObject& args, std::function<void(const QJsonObject&)> done) {
auto* job = new Job;
job->done = std::move(done);
job->toolCallId = toolCallId;
job->budgetChars = outputBudgetChars;
m_jobs.append(job);
auto fail = [this, job](const QString& message) {
@@ -246,63 +233,13 @@ void WebFetchTool::execute(
if (mime.contains(QLatin1String("text/html")) &&
job->format == QLatin1String("text"))
content = extractTextFromHtml(content);
const QString savedPath = saveToFile(*job, content, mime);
QString output = content;
int budget = inlineBudgetChars();
if (job->budgetChars > 0) budget = qMin(budget, job->budgetChars);
if (content.size() > budget) {
output = content.left(budget);
const QString note =
QStringLiteral(
"\n\n[... truncated: showing %1 of %2 characters")
.arg(budget)
.arg(content.size());
if (!savedPath.isEmpty())
output += note + QStringLiteral(
". The full content is saved to %1; use "
"the readfile tool to read the rest.")
.arg(savedPath);
else
output += note + QStringLiteral(
". The remaining content is not "
"available.");
}
completeJob(job, makeOutput(output));
if (content.size() > MaxOutputChars)
content = content.left(MaxOutputChars) +
QStringLiteral("\n[... truncated ...]");
completeJob(job, makeOutput(content));
});
}
QString WebFetchTool::saveToFile(
const Job& job, const QString& content, const QString& mime) const {
QDir dir(StoragePath);
if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) return QString();
QString fileName;
fileName.reserve(job.toolCallId.size());
for (const QChar& c : job.toolCallId)
fileName += (c.isLetterOrNumber() || c == QLatin1Char('-') ||
c == QLatin1Char('_'))
? c
: QLatin1Char('_');
if (fileName.isEmpty()) fileName = QStringLiteral("fetch");
QString extension = QStringLiteral("txt");
if (job.format == QLatin1String("html"))
extension = QStringLiteral("html");
else if (mime.contains(QLatin1String("json")))
extension = QStringLiteral("json");
else if (mime.contains(QLatin1String("xml")))
extension = QStringLiteral("xml");
const QString path = dir.filePath(fileName + QLatin1Char('.') + extension);
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
return QString();
file.write(content.toUtf8());
return path;
}
void WebFetchTool::cancel() {
for (auto* job : m_jobs) {
job->timer->stop();
+1 -7
View File
@@ -20,7 +20,7 @@ class WebFetchTool : public LlmTool {
static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
static constexpr int DefaultTimeoutSeconds = 30;
static constexpr int MaxTimeoutSeconds = 120;
static const QString StoragePath;
static constexpr int MaxOutputChars = 64 * 1024;
explicit WebFetchTool(QObject* parent = nullptr);
~WebFetchTool() override;
@@ -29,9 +29,7 @@ class WebFetchTool : public LlmTool {
QString description() const override;
QJsonObject parameters() const override;
void execute(
const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override;
void cancel() override;
@@ -45,14 +43,10 @@ class WebFetchTool : public LlmTool {
QByteArray body;
bool tooLarge = false;
QString format;
QString toolCallId;
int budgetChars = 0;
std::function<void(const QJsonObject& result)> done;
};
void completeJob(Job* job, QJsonObject result);
QString saveToFile(
const Job& job, const QString& content, const QString& mime) const;
QNetworkAccessManager m_manager;
QList<Job*> m_jobs;
+2 -5
View File
@@ -163,7 +163,6 @@ void Gpu::tick() {
const Type t = type();
if (t == Generic) {
readGenericUsage();
readGenericMemory();
readGpuTemperature();
} else if (t == Nvidia) {
startNvidiaUsage();
@@ -335,10 +334,8 @@ void Gpu::startNvidiaUsage() {
const qreal usage =
parts.at(0).trimmed().toDouble(&ok1) / 100.0;
const qreal temp = parts.at(1).trimmed().toDouble(&ok2);
const qreal memUsed =
parts.at(2).trimmed().toDouble(&ok3) * 1024;
const qreal memTotal =
parts.at(3).trimmed().toDouble(&ok4) * 1024;
const qreal memUsed = parts.at(2).trimmed().toDouble(&ok3);
const qreal memTotal = parts.at(3).trimmed().toDouble(&ok4);
if (ok1 && std::abs(usage - m_percentage) > 0.0001) {
m_percentage = usage;
+6 -151
View File
@@ -5,17 +5,13 @@
#include <QtQuick/qquickwindow.h>
#include <qcontainerfwd.h>
#include <qdir.h>
#include <qdiriterator.h>
#include <qfileinfo.h>
#include <qfuturewatcher.h>
#include <qjsprimitivevalue.h>
#include <qloggingcategory.h>
#include <qregularexpression.h>
#include <qqmlengine.h>
#include <qfile.h>
#include "util/metaenum.hpp"
Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg)
namespace ZShell {
@@ -175,112 +171,13 @@ qreal ZUtils::clamp(qreal value, qreal min, qreal max) {
return qBound(min, value, max);
}
QString ZUtils::enumToString(
QObject* target, const QString& property, const QVariant& value) {
if (!target) {
qCWarning(lcZUtils) << "enumToString: a target is required";
return {};
QString ZUtils::settingsIndex() {
QFile file(QStringLiteral(":/qt/qml/ZShell/settings-index.json"));
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
qCWarning(lcZUtils) << "Failed to open embedded settings index";
return QString();
}
const auto* meta = target->metaObject();
const auto index = meta->indexOfProperty(property.toUtf8().constData());
if (index < 0) {
qCWarning(lcZUtils)
<< "enumToString:" << target << "has no property" << property;
return {};
}
const auto prop = meta->property(index);
const auto metaEnum = prop.isEnumType()
? prop.enumerator()
: util::metaEnumFor(prop.metaType());
if (!metaEnum.isValid() || metaEnum.is64Bit()) {
qCWarning(lcZUtils) << "enumToString: property" << property << "of"
<< target << "is not a supported enum";
return {};
}
const auto val = value.isValid() ? value : prop.read(target);
const auto* key = util::enumKeyFor(metaEnum, val);
if (!key) {
qCWarning(
lcZUtils,
"enumToString: no enumerator of %s::%s has the value %lld",
metaEnum.scope(),
metaEnum.name(),
val.toLongLong());
return {};
}
return QString::fromUtf8(key);
}
namespace {
template <typename Predicate>
QQuickItem* findChildDfs(QQuickItem* root, Predicate&& match) {
const auto children = root->childItems();
for (QQuickItem* const child : children) {
if (match(child)) {
return child;
}
if (QQuickItem* const found = findChildDfs(child, match)) {
return found;
}
}
return nullptr;
}
template <typename Predicate>
void findChildrenDfs(
QQuickItem* root, Predicate&& match, QList<QQuickItem*>& out) {
const auto children = root->childItems();
for (QQuickItem* const child : children) {
if (match(child)) {
out.append(child);
}
findChildrenDfs(child, match, out);
}
}
} // namespace
QQuickItem* ZUtils::findChild(QQuickItem* root, const QString& name) {
if (!root) {
return nullptr;
}
return findChildDfs(root, [&name](const QQuickItem* item) {
return item->objectName() == name;
});
}
QList<QQuickItem*> ZUtils::findChildren(QQuickItem* root, const QString& name) {
QList<QQuickItem*> children;
if (root) {
findChildrenDfs(
root,
[&name](const QQuickItem* item) {
return item->objectName() == name;
},
children);
}
return children;
}
QList<QQuickItem*> ZUtils::findChildrenMatching(
QQuickItem* root, const QString& pattern) {
QList<QQuickItem*> children;
if (root) {
const QRegularExpression re(pattern);
findChildrenDfs(
root,
[&re](const QQuickItem* item) {
return re.match(item->objectName()).hasMatch();
},
children);
}
return children;
return QString::fromUtf8(file.readAll());
}
#ifndef ZSHELL_VERSION
@@ -295,46 +192,4 @@ QString ZUtils::qtVersion() const {
return QStringLiteral(QT_VERSION_STR);
}
QString ZUtils::gitRevision() {
#ifdef GIT_REVISION
return QStringLiteral(GIT_REVISION);
#else
return QString();
#endif
}
QString ZUtils::readTextFile(const QString& path) {
QFile file(path);
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
return QString();
}
return QString::fromUtf8(file.readAll());
}
bool ZUtils::writeTextFile(const QString& path, const QString& text) {
const QFileInfo info(path);
if (!QDir().mkpath(info.absolutePath())) {
qCWarning(lcZUtils) << "Failed to create directory for" << path;
return false;
}
QFile file(path);
if (!file.open(QIODevice::WriteOnly | QIODevice::Text)) {
qCWarning(lcZUtils) << "Failed to open" << path << "for writing";
return false;
}
return file.write(text.toUtf8()) >= 0;
}
QStringList ZUtils::listFiles(const QString& dir, const QString& suffix) {
QStringList out;
QDirIterator it(dir, QDirIterator::Subdirectories);
while (it.hasNext()) {
const QString path = it.next();
if (it.fileInfo().isFile() && path.endsWith(suffix)) {
out.append(path);
}
}
return out;
}
} // namespace ZShell
+1 -20
View File
@@ -4,8 +4,6 @@
#include <qcontainerfwd.h>
#include <qobject.h>
#include <qqmlintegration.h>
#include <qtmetamacros.h>
#include <qvariant.h>
namespace ZShell {
@@ -34,24 +32,7 @@ class ZUtils : public QObject {
Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max);
Q_INVOKABLE static QString enumToString(
QObject* target,
const QString& property,
const QVariant& value = QVariant());
Q_INVOKABLE static QString gitRevision();
Q_INVOKABLE static QString readTextFile(const QString& path);
Q_INVOKABLE static bool writeTextFile(
const QString& path, const QString& text);
Q_INVOKABLE static QStringList listFiles(
const QString& dir, const QString& suffix);
Q_INVOKABLE static QQuickItem* findChild(
QQuickItem* root, const QString& name);
Q_INVOKABLE static QList<QQuickItem*> findChildren(
QQuickItem* root, const QString& name);
Q_INVOKABLE static QList<QQuickItem*> findChildrenMatching(
QQuickItem* root, const QString& pattern);
Q_INVOKABLE static QString settingsIndex();
[[nodiscard]] QString version() const;
[[nodiscard]] QString qtVersion() const;
-14
View File
@@ -1,14 +0,0 @@
add_library(zshell-pch INTERFACE)
target_precompile_headers(zshell-pch INTERFACE
<qobject.h>
<qqmlintegration.h>
<qstring.h>
<qqmlengine.h>
<qloggingcategory.h>
<qvariant.h>
<qtimer.h>
<qdir.h>
<qlist.h>
<qstringlist.h>
<qpointer.h>
)
-55
View File
@@ -1,55 +0,0 @@
message(STATUS "QML install dir: ${CMAKE_INSTALL_PREFIX}/${INSTALL_QMLDIR}")
function(qml_module arg_TARGET)
cmake_parse_arguments(PARSE_ARGV 1 arg "" "URI" "SOURCES;QML_FILES;QML_SINGLETONS;DEPENDENCIES;IMPORTS;OPTIONAL_IMPORTS;DEFAULT_IMPORTS;LIBRARIES")
set_source_files_properties(${arg_QML_SINGLETONS} PROPERTIES QT_QML_SINGLETON_TYPE TRUE)
qt_add_qml_module(${arg_TARGET}
URI ${arg_URI}
SOURCES ${arg_SOURCES}
QML_FILES ${arg_QML_FILES} ${arg_QML_SINGLETONS}
DEPENDENCIES ${arg_DEPENDENCIES}
IMPORTS ${arg_IMPORTS}
OPTIONAL_IMPORTS ${arg_OPTIONAL_IMPORTS}
DEFAULT_IMPORTS ${arg_DEFAULT_IMPORTS}
)
qt_query_qml_module(${arg_TARGET}
URI module_uri
PLUGIN_TARGET module_plugin_target
TARGET_PATH module_target_path
QMLDIR module_qmldir
TYPEINFO module_typeinfo
)
message(STATUS "Created QML module: ${module_uri}")
string(REPLACE "/" ";" uri_parts "${module_target_path}")
list(GET uri_parts 0 top_level)
set(backing_lib_dir "${INSTALL_QMLDIR}/${top_level}/lib")
set(module_dir "${INSTALL_QMLDIR}/${module_target_path}")
install(TARGETS ${arg_TARGET}
LIBRARY DESTINATION "${backing_lib_dir}"
RUNTIME DESTINATION "${backing_lib_dir}"
)
install(TARGETS "${module_plugin_target}"
LIBRARY DESTINATION "${module_dir}"
RUNTIME DESTINATION "${module_dir}"
)
install(FILES "${module_qmldir}" DESTINATION "${module_dir}")
install(FILES "${module_typeinfo}" DESTINATION "${module_dir}")
target_link_libraries(${arg_TARGET} PRIVATE zshell-pch Qt::Core Qt::Qml ${arg_LIBRARIES})
if(arg_INCLUDE_PREFIX)
set(include_dir "${CMAKE_CURRENT_BINARY_DIR}/include")
file(MAKE_DIRECTORY "${include_dir}")
file(CREATE_LINK "${CMAKE_CURRENT_SOURCE_DIR}" "${include_dir}/${arg_INCLUDE_PREFIX}" SYMBOLIC)
target_include_directories(${arg_TARGET} PUBLIC "${include_dir}")
endif()
file(RELATIVE_PATH plugin_to_lib "/${module_target_path}" "/${top_level}/lib")
set_property(TARGET ${module_plugin_target} APPEND PROPERTY INSTALL_RPATH "$ORIGIN/${plugin_to_lib}")
endfunction()
-10
View File
@@ -1,10 +0,0 @@
find_library(SENSORS_LIBRARY NAMES sensors REQUIRED)
find_path(SENSORS_INCLUDE_DIR NAMES sensors/sensors.h REQUIRED)
if(NOT TARGET Sensors::Sensors)
add_library(Sensors::Sensors UNKNOWN IMPORTED)
set_target_properties(Sensors::Sensors PROPERTIES
IMPORTED_LOCATION "${SENSORS_LIBRARY}"
INTERFACE_INCLUDE_DIRECTORIES "${SENSORS_INCLUDE_DIR}"
)
endif()
-31
View File
@@ -1,31 +0,0 @@
#pragma once
#include <qmetaobject.h>
#include <qmetatype.h>
#include <qvariant.h>
namespace util {
inline QMetaEnum metaEnumFor(const QMetaType& type) {
const auto* meta = type.metaObject();
if (!meta) return {};
auto name = QByteArray(type.name());
if (const auto scope = name.lastIndexOf("::"); scope >= 0)
name = name.mid(scope + 2);
return meta->enumerator(meta->indexOfEnumerator(name.constData()));
}
inline bool isSupportedEnum(const QMetaType& type) {
if (!type.flags().testFlag(QMetaType::IsEnumeration)) return false;
const auto metaEnum = metaEnumFor(type);
return metaEnum.isValid() && !metaEnum.is64Bit();
}
inline const char* enumKeyFor(const QMetaEnum& metaEnum, const QVariant& value) {
return metaEnum.valueToKey(static_cast<quint64>(value.toLongLong()));
}
} // namespace util
+6 -13
View File
@@ -15,6 +15,7 @@ It includes a configurable top bar, launcher, notifications daemon + sidebar, wa
## Highlights
- Multi-window shell layout driven by `Drawers/Windows.qml` and panel/interactions wrappers
- Configurable bar entries and popouts (audio, tray, network, power, resources, clock, active window)
- Launcher with app DB frequency tracking, action commands, fuzzy search modes, and wallpaper/scheme flows
- Notification server with persistence (`~/.local/state/zshell/notifs.json`) and sidebar UI
- Dynamic Material 3 palette generation from wallpaper, optional template rendering, terminal sequence application
@@ -46,25 +47,16 @@ Core requirements:
- Hyprland (Wayland session integration)
- Python 3 for scheme/wallpaper tooling
Here's a list of package dependencies, names are from arch-packages so they
might differ slightly depending on your distro.
```
python python-pillow python-materialyoucolor libnotify cava
app2unit wl-clipboard dconf cliphist python-typer qt6-canvaspainter
python-build python-installer python-hatch python-hatch-vcs cmake ninja
```
Make sure to have the newest Quickshell version! As of writing, version `0.3.1.r1.g0fed22a-1`.
Make sure to have the newest Quickshell version! As of writing, version `0.2.0.r136.gfb08ece-1`.
Used by major features (install as needed for your setup):
- `app2unit` (launcher app execution)
- `nmcli` (network integration)
- `brightnessctl`, `ddcutil` (brightness controls)
- `wl-copy` (clipboard integration)
- `PipeWire` + audio stack + `aubio`/`cava` paths for media visualization
- `wl-copy`, `swappy` (picker/screenshot flow)
- `libqalculate` (launcher calculator)
- `PipeWire` + audio stack + `aubio`/`cava` paths for media visualization
- `gsettings` (optional GTK dark/light mode sync)
## Build and Install
@@ -188,7 +180,7 @@ Important state/cache files:
- `~/.local/state/zshell/apps.sqlite`
- `~/.cache/zshell/`
Config is hot-reloaded and saved through the `Config` singleton. Top-level sections include:
Config is hot-reloaded and saved through `Config/Config.qml` serializers. Top-level sections include:
- `general`
- `appearance`
@@ -273,6 +265,7 @@ Note: Template rendering (Jinja2) applies generated colors to `~/.config/zshell/
### `screenshot` — area picker
- `start` — open interactive area picker
- `start-freeze` — freeze screen then pick
### `wallpaper` — wallpaper management
-1
View File
@@ -11,7 +11,6 @@ RUN pacman -S --noconfirm \
lld \
nodejs \
tree-sitter \
jkqtplotter \
cmark-gfm \
git
+452
View File
@@ -0,0 +1,452 @@
from __future__ import annotations
import json
import re
import sys
from collections import defaultdict
from functools import cache
from pathlib import Path
@cache
def read_lines(path: Path) -> tuple[str, ...]:
return tuple(path.read_text().splitlines())
ROW_RE = re.compile(
r"^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|InfoRow|PopupRow|OverlayRow|DefaultRow)\s*\{"
)
LABEL_RE = re.compile(r'^\s*(?:label|text):\s*qsTr\("([^"]+)"\)')
ANCHOR_RE = re.compile(r'^\s*settingAnchor:\s*"([^"]+)"')
CHECKED_RE = re.compile(r"^\s*checked:\s*(?:Config)\.([\w.]+)\s*$")
ONTOGGLED_RE = re.compile(
r"^\s*onToggled:\s*(?:Config)\.([\w.]+)\s*=\s*checked\s*$"
)
ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
SKIP_LABELS = {"Muted", "None"}
FIELD_WEIGHT = {"title": 1.0, "keywords": 0.4}
STOPWORDS = {"the", "a", "an", "of", "and", "or", "to", "on", "in", "for"}
def find_pages_dir(settings: Path) -> Path:
return settings / "Pages"
def discover_files(settings: Path) -> dict[str, Path]:
files: dict[str, Path] = {}
for p in find_pages_dir(settings).rglob("*.qml"):
files[p.stem] = p
return files
PAGE_NAME_RE = re.compile(r'^\s*name:\s*qsTr\("([^"]+)"\)')
PAGE_ICON_RE = re.compile(r'^\s*icon:\s*"([^"]+)"')
def parse_page_registry(settings: Path) -> list[tuple[str, str]]:
text = (settings / "PageRegistry.qml").read_text().splitlines()
start = next(
i for i, line in enumerate(text) if re.search(r"\bpages\s*:\s*\[", line)
)
out: list[tuple[str, str]] = []
i = start + 1
while i < len(text):
line = text[i].strip()
if line.startswith("]"):
break
if line.startswith("//") or not line:
i += 1
continue
if line.startswith("{"):
name = None
icon = None
i += 1
while i < len(text):
s = text[i].strip()
if s.startswith("}"):
if name is not None:
out.append((icon or "tune", name))
break
if name is None:
m = PAGE_NAME_RE.match(text[i])
if m:
name = m.group(1)
if icon is None:
mi = PAGE_ICON_RE.match(text[i])
if mi:
icon = mi.group(1)
i += 1
i += 1
return out
BLOCK_RE = re.compile(r"^\s*([A-Za-z_][A-Za-z0-9_]*)\s*\{\s*$")
def _strip_comment(line: str) -> str:
return line.split("//", 1)[0].rstrip()
def parse_block(
lines: list[str], i: int
) -> tuple[str, list[tuple[str, list]], int]:
line = _strip_comment(lines[i]).strip()
m = BLOCK_RE.match(line)
if not m:
raise ValueError(f"Expected block start at line {i + 1}: {lines[i]!r}")
name = m.group(1)
i += 1
children: list[tuple[str, list]] = []
while i < len(lines):
s = _strip_comment(lines[i]).strip()
if not s:
i += 1
continue
if s.startswith("}"):
return name, children, i + 1
if BLOCK_RE.match(s):
child_name, child_children, i = parse_block(lines, i)
children.append((child_name, child_children))
continue
i += 1
raise ValueError(f"Unterminated block: {name}")
def collect_page_names(block: tuple[str, list[tuple[str, list]]]) -> list[str]:
name, children = block
if name != "Component":
return [name]
for child_name, child_children in children:
if child_name == "StackPage":
out: list[str] = []
for grand_name, grand_children in child_children:
if grand_name == "Component":
out.extend(collect_page_names((grand_name, grand_children)))
return out
if child_name != "Component":
return [child_name]
return []
def parse_page_comps(settings: Path) -> list[list[str]]:
text = (settings / "PageCompRegistry.qml").read_text().splitlines()
start = next(
i
for i, line in enumerate(text)
if re.search(r"\bpageComps\s*:\s*\[", _strip_comment(line))
)
comps: list[list[str]] = []
i = start + 1
while i < len(text):
s = _strip_comment(text[i]).strip()
if not s:
i += 1
continue
if s.startswith("]"):
break
if BLOCK_RE.match(s) and BLOCK_RE.match(s).group(1) == "Component":
block = parse_block(text, i)
names = collect_page_names((block[0], block[1]))
if names:
comps.append(names)
i = block[2]
continue
i += 1
return comps
def dedup_crumbs(
labels: list[str], icons: list[str]
) -> tuple[list[str], list[str]]:
out_labels: list[str] = []
out_icons: list[str] = []
for lbl, ico in zip(labels, icons, strict=False):
if out_labels and out_labels[-1] == lbl:
continue
out_labels.append(lbl)
out_icons.append(ico)
return out_labels, out_icons
def build_nav_map(settings: Path, files: dict[str, Path]) -> dict[str, dict]:
comps = parse_page_comps(settings)
registry = parse_page_registry(settings)
top_meta: dict[int, tuple[str, str]] = {}
for i, (icon, label) in enumerate(registry):
top_meta[i] = (icon, label)
nav_children: dict[str, dict[int, tuple[str, str, str]]] = {}
for names in comps:
for name in names:
pf = files.get(name)
if not pf:
continue
pending_icon = pending_label = None
section = ""
expect_section = False
for ln in read_lines(pf):
if SECTION_RE.match(ln):
expect_section = True
continue
ml = LABEL_RE.match(ln)
if ml:
if expect_section:
section = ml.group(1)
expect_section = False
else:
pending_label = ml.group(1)
continue
mi = ICON_RE.match(ln)
if mi:
pending_icon = mi.group(1)
mo = re.search(r"openSubPage\((\d+)\)", ln)
if mo:
pos = int(mo.group(1))
nav_children.setdefault(name, {})[pos] = (
pending_icon or "tune",
pending_label or "",
section,
)
pending_icon = pending_label = None
nav: dict[str, dict] = {}
for top_idx, names in enumerate(comps):
if not names:
continue
main = names[0]
main_icon, main_label = top_meta.get(top_idx, ("tune", main))
nav[main] = {
"pageIdx": top_idx,
"subPath": [],
"crumbIcons": [main_icon],
"crumbLabels": [main_label],
}
children = dict(nav_children.get(main, {}))
opened_via_subpage = set()
for owner, kids in nav_children.items():
owner_group = next((ns for ns in comps if owner in ns), None)
if not owner_group:
continue
for kpos in kids:
if kpos < len(owner_group):
opened_via_subpage.add(owner_group[kpos])
for pos in range(1, len(names)):
if pos not in children and names[pos] not in opened_via_subpage:
label = re.sub(r"(Detail)?Page$", "", names[pos])
label = re.sub(r"(?<!^)(?=[A-Z])", " ", label)
children[pos] = (main_icon, label, "")
for pos, (icon, label, section) in children.items():
if pos >= len(names):
continue
child = names[pos]
labels = [main_label] + ([section] if section else []) + [label]
icons = [main_icon] + ([icon] if section else []) + [icon]
labels, icons = dedup_crumbs(labels, icons)
nav[child] = {
"pageIdx": top_idx,
"subPath": [pos],
"crumbIcons": icons,
"crumbLabels": labels,
}
for gpos, (gicon, glabel, gsection) in nav_children.get(
child, {}
).items():
if gpos >= len(names):
continue
glabels = labels + ([gsection] if gsection else []) + [glabel]
gicons = icons + ([gicon] if gsection else []) + [gicon]
glabels, gicons = dedup_crumbs(glabels, gicons)
nav[names[gpos]] = {
"pageIdx": top_idx,
"subPath": [pos, gpos],
"crumbIcons": gicons,
"crumbLabels": glabels,
}
return nav
def tokenize(text: str) -> list[str]:
toks: list[str] = []
for word in text.lower().split():
parts = [p for p in re.split(r"[^a-z0-9]+", word) if p]
for p in parts:
if p not in STOPWORDS and p not in toks:
toks.append(p)
if len(parts) > 1:
joined = "".join(parts)
if joined not in toks:
toks.append(joined)
return toks
SUBTEXT_RE = re.compile(r'^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)')
SECTION_RE = re.compile(r"^\s*SectionHeader\s*\{")
def extract_settings(
files: dict[str, Path], nav: dict[str, dict]
) -> list[dict]:
entries: list[dict] = []
for comp, meta in nav.items():
pf = files.get(comp)
if not pf:
continue
lines = read_lines(pf)
section = ""
i = 0
while i < len(lines):
if SECTION_RE.match(lines[i]):
for j in range(i + 1, min(i + 4, len(lines))):
m = LABEL_RE.match(lines[j])
if m:
section = m.group(1)
break
row_match = ROW_RE.match(lines[i])
if row_match:
row_type = row_match.group(1)
label = anchor = subtext = None
checked_path = toggled_path = None
for j in range(i + 1, min(i + 12, len(lines))):
if label is None:
m = LABEL_RE.match(lines[j])
if m:
label = m.group(1)
if anchor is None:
a = ANCHOR_RE.match(lines[j])
if a:
anchor = a.group(1)
if subtext is None:
st = SUBTEXT_RE.match(lines[j])
if st:
subtext = st.group(1)
if checked_path is None:
ch = CHECKED_RE.match(lines[j])
if ch:
checked_path = ch.group(1)
if toggled_path is None:
tg = ONTOGGLED_RE.match(lines[j])
if tg:
toggled_path = tg.group(1)
toggle_path = (
checked_path
if row_type == "ToggleRow"
and checked_path
and checked_path == toggled_path
else ""
)
if label and label not in SKIP_LABELS and anchor:
extra = (
" ".join(meta["crumbLabels"])
+ " "
+ section
+ " "
+ (subtext or "")
)
entries.append(
{
"pageIdx": meta["pageIdx"],
"subPath": meta["subPath"],
"crumbIcons": meta["crumbIcons"],
"crumbLabels": meta["crumbLabels"],
"title": label,
"anchor": anchor,
"section": section,
"subtext": subtext or "",
"togglePath": toggle_path,
"keywords": " ".join(
sorted(set(tokenize(label + " " + extra)))
),
}
)
i += 1
return entries
def build_inverted_and_ranking(entries: list[dict]):
inverted: dict[str, list[int]] = defaultdict(list)
ranking: dict[str, dict[int, float]] = defaultdict(dict)
for idx, e in enumerate(entries):
fields = {"title": e["title"], "keywords": e["keywords"]}
seen: set[str] = set()
for field, text in fields.items():
weight = FIELD_WEIGHT.get(field, 0.2)
for tok in tokenize(text):
if idx not in inverted[tok]:
inverted[tok].append(idx)
ranking[tok][idx] = max(ranking[tok].get(idx, 0.0), weight)
seen.add(tok)
for tok, ids in inverted.items():
ids.sort(key=lambda i: ranking[tok][i], reverse=True)
return inverted, {
t: {str(k): v for k, v in d.items()} for t, d in ranking.items()
}
def main() -> int:
if len(sys.argv) != 3:
print(__doc__)
return 1
settings = Path(sys.argv[1])
out = Path(sys.argv[2])
files = discover_files(settings)
nav = build_nav_map(settings, files)
entries = extract_settings(files, nav)
inverted, ranking = build_inverted_and_ranking(entries)
for e in entries:
e.pop("keywords", None)
out.write_text(
json.dumps(
{
"version": 2,
"entries": entries,
"inverted": inverted,
"ranking": ranking,
},
ensure_ascii=False,
indent=2,
)
)
print(
f"settings index: {len(entries)} entries, "
f"{len(inverted)} tokens -> {out}"
)
print("files:", len(files))
print("comps:", len(parse_page_comps(settings)))
print("registry:", len(parse_page_registry(settings)))
print("nav:", len(nav))
print("entries:", len(entries))
return 0
if __name__ == "__main__":
sys.exit(main())
-450
View File
@@ -1,450 +0,0 @@
.pragma library
const STOPWORDS = [
"a",
"an",
"and",
"are",
"for",
"in",
"not",
"notification",
"of",
"on",
"or",
"out",
"the",
"to",
];
const FIELD_WEIGHT = {
title: 1.0,
keywords: 0.4,
};
const SKIP_LABELS = ["Muted", "None"];
function cleanLabel(text) {
return String(text ?? "")
.replace(/\s*\(?%\d+\)?/g, "")
.trim();
}
const PAGE_NAME_RE = /^\s*name:\s*qsTr\("([^"]+)"\)/;
const ROW_RE =
/^\s*(ToggleRow|SliderRow|SelectRow|SpinRow|NavRow|RowButton|InfoRow|PopupRow|DefaultRow|TextFieldRow|IconTextButton|TimeDialogSelect)\s*\{/;
const NAV_ROW_TYPES = ["NavRow", "IconTextButton"];
const LABEL_RE = /^\s*(?:label|text):\s*qsTr\("([^"]+)"\)/;
const ANCHOR_RE = /^\s*(?:property\s+string\s+)?settingAnchor:\s*"([^"]+)"/;
const CHECKED_RE = /^\s*checked:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*$/;
const ONTOGGLED_RE =
/^\s*onToggled:\s*(?:GlobalConfig|Config)\.([\w.]+)\s*=\s*checked\s*$/;
const ICON_RE = /^\s*icon:\s*"([^"]+)"/;
const SUBTEXT_RE = /^\s*(?:subtext|status):\s*qsTr\("([^"]+)"\)/;
const SECTION_RE = /^\s*SectionHeader\s*\{/;
function tokenize(text) {
const toks = [];
for (const word of text.toLowerCase().split(/\s+/)) {
if (!word) continue;
const parts = word.split(/[^a-z0-9]+/).filter((p) => p);
for (const p of parts) {
if (!STOPWORDS.includes(p) && !toks.includes(p)) toks.push(p);
}
if (parts.length > 1) {
const joined = parts.join("");
if (!toks.includes(joined)) toks.push(joined);
}
}
return toks;
}
function discoverFiles(settingsDir, listFiles) {
const files = {};
for (const p of listFiles(`${settingsDir}/Pages`, ".qml")) {
const name = p.slice(p.lastIndexOf("/") + 1).replace(/\.qml$/, "");
files[name] = p;
}
return files;
}
function parsePageRegistry(settingsDir, readLines) {
const lines = readLines(`${settingsDir}/PageRegistry.qml`);
const out = [];
let inArray = false;
let depth = 0;
let label = null;
let icon = null;
for (const line of lines) {
const s = line.trim();
if (s.includes("pages:") && s.includes("[")) {
inArray = true;
continue;
}
if (!inArray) continue;
if (s.startsWith("//")) continue;
if (s.startsWith("]")) break;
if (s.startsWith("{")) {
depth++;
label = icon = null;
continue;
}
if (s.startsWith("}")) {
if (label !== null) out.push([icon || "tune", label]);
depth--;
continue;
}
if (depth >= 1) {
const m = PAGE_NAME_RE.exec(line);
if (m && label === null) label = m[1];
const mi = ICON_RE.exec(line);
if (mi && icon === null) icon = mi[1];
}
}
return out;
}
function parsePageComps(settingsDir, readFile) {
const text = readFile(`${settingsDir}/PageCompRegistry.qml`);
const start = text.indexOf("pageComps:");
if (start === -1) return [];
const comps = [];
let current = null;
let depth = 0;
for (const raw of text.slice(start).split("\n")) {
const line = raw.split("//")[0];
const s = line.trim();
if (s.startsWith("]")) break;
const atTop = depth === 0;
depth +=
(line.match(/\{/g) ?? []).length -
(line.match(/\}/g) ?? []).length;
if (atTop && /^Component\s*\{/.test(s)) {
current = [];
comps.push(current);
continue;
}
if (current !== null) {
const m = /(^|\s)([A-Z][A-Za-z0-9]*)\s*\{\s*\}/.exec(s);
if (m) current.push(m[2]);
}
}
return comps;
}
function dedupCrumbs(labels, icons) {
const outLabels = [];
const outIcons = [];
for (let i = 0; i < labels.length; i++) {
if (
outLabels.length > 0 &&
outLabels[outLabels.length - 1] === labels[i]
)
continue;
outLabels.push(labels[i]);
outIcons.push(icons[i]);
}
return [outLabels, outIcons];
}
function buildNavMap(settingsDir, files, readFile, readLines) {
const comps = parsePageComps(settingsDir, readFile);
const registry = parsePageRegistry(settingsDir, readLines);
const navChildren = {};
for (const names of comps) {
for (const name of names) {
const pf = files[name];
if (!pf) continue;
let pendingIcon = null;
let pendingLabel = null;
let section = "";
let expectSection = false;
for (const ln of readLines(pf)) {
if (SECTION_RE.test(ln)) {
expectSection = true;
continue;
}
const ml = LABEL_RE.exec(ln);
if (ml) {
if (expectSection) {
section = ml[1];
expectSection = false;
} else {
pendingLabel = ml[1];
}
continue;
}
const mi = ICON_RE.exec(ln);
if (mi) pendingIcon = mi[1];
const mo = /openSubPage\((\d+)\)/.exec(ln);
if (mo) {
const pos = parseInt(mo[1], 10);
if (!navChildren[name]) navChildren[name] = {};
navChildren[name][pos] = [
pendingIcon || "tune",
pendingLabel || "",
section,
];
pendingIcon = pendingLabel = null;
}
}
}
}
const nav = {};
for (let topIdx = 0; topIdx < comps.length; topIdx++) {
const names = comps[topIdx];
if (names.length === 0) continue;
const main = names[0];
const [mainIcon, mainLabel] = registry[topIdx] ?? ["tune", main];
nav[main] = {
pageIdx: topIdx,
subPath: [],
crumbIcons: [mainIcon],
crumbLabels: [mainLabel],
};
const children = Object.assign({}, navChildren[main] ?? {});
const openedViaSubpage = [];
for (const owner in navChildren) {
const ownerGroup = comps.find((ns) => ns.includes(owner));
if (!ownerGroup) continue;
for (const kpos in navChildren[owner]) {
const k = parseInt(kpos, 10);
if (k < ownerGroup.length) openedViaSubpage.push(ownerGroup[k]);
}
}
for (let pos = 1; pos < names.length; pos++) {
if (!(pos in children) && !openedViaSubpage.includes(names[pos])) {
let label = names[pos].replace(/(Detail)?Page$/, "");
label = label.replace(/(?<!^)(?=[A-Z])/g, " ");
children[pos] = [mainIcon, label, ""];
}
}
for (const posKey in children) {
const pos = parseInt(posKey, 10);
if (pos >= names.length) continue;
const [icon, label, section] = children[posKey];
const child = names[pos];
let labels = [mainLabel]
.concat(section ? [section] : [])
.concat([label]);
let icons = [mainIcon].concat(section ? [icon] : []).concat([icon]);
[labels, icons] = dedupCrumbs(labels, icons);
nav[child] = {
pageIdx: topIdx,
subPath: [pos],
crumbIcons: icons,
crumbLabels: labels,
};
const grandChildren = navChildren[child] ?? {};
for (const gposKey in grandChildren) {
const gpos = parseInt(gposKey, 10);
if (gpos >= names.length) continue;
const [gicon, glabel, gsection] = grandChildren[gposKey];
let glabels = labels
.concat(gsection ? [gsection] : [])
.concat([glabel]);
let gicons = icons
.concat(gsection ? [gicon] : [])
.concat([gicon]);
[glabels, gicons] = dedupCrumbs(glabels, gicons);
nav[names[gpos]] = {
pageIdx: topIdx,
subPath: [pos, gpos],
crumbIcons: gicons,
crumbLabels: glabels,
};
}
}
}
return nav;
}
function findBlockEnd(lines, start) {
let depth = 0;
for (let i = start; i < lines.length; i++) {
const line = lines[i].split("//")[0];
depth +=
(line.match(/\{/g) ?? []).length -
(line.match(/\}/g) ?? []).length;
if (i > start && depth <= 0) return i;
}
return lines.length;
}
function extractSettings(files, nav, comps, readLines) {
const entries = [];
for (const comp in nav) {
const meta = nav[comp];
const pf = files[comp];
if (!pf) continue;
const lines = readLines(pf);
let section = "";
for (let i = 0; i < lines.length; i++) {
if (SECTION_RE.test(lines[i])) {
for (let j = i + 1; j < Math.min(i + 4, lines.length); j++) {
const m = LABEL_RE.exec(lines[j]);
if (m) {
section = m[1];
break;
}
}
}
const rowMatch = ROW_RE.exec(lines[i]);
if (!rowMatch) continue;
const rowType = rowMatch[1];
let label = null;
let anchor = null;
let subtext = null;
let checkedPath = null;
let toggledPath = null;
let targetPos = null;
const rowEnd = findBlockEnd(lines, i);
for (let j = i + 1; j < rowEnd; j++) {
if (label === null) {
const m = LABEL_RE.exec(lines[j]);
if (m) label = m[1];
}
if (anchor === null) {
const a = ANCHOR_RE.exec(lines[j]);
if (a) anchor = a[1];
}
if (subtext === null) {
const st = SUBTEXT_RE.exec(lines[j]);
if (st) subtext = st[1];
}
if (checkedPath === null) {
const ch = CHECKED_RE.exec(lines[j]);
if (ch) checkedPath = ch[1];
}
if (toggledPath === null) {
const tg = ONTOGGLED_RE.exec(lines[j]);
if (tg) toggledPath = tg[1];
}
if (NAV_ROW_TYPES.includes(rowType) && targetPos === null) {
const sp = /openSubPage\((\d+)\)/.exec(lines[j]);
if (sp) targetPos = parseInt(sp[1], 10);
}
}
const togglePath =
rowType === "ToggleRow" &&
checkedPath &&
checkedPath === toggledPath
? checkedPath
: "";
if (label && !SKIP_LABELS.includes(label) && anchor) {
const extra =
meta.crumbLabels.join(" ") +
" " +
section +
" " +
(subtext && !/%\d/.test(subtext) ? subtext : "");
const group = comps[meta.pageIdx] ?? [];
const targetSubPath =
targetPos !== null && targetPos < group.length
? meta.subPath.concat([targetPos])
: [];
entries.push({
rowType: rowType,
pageIdx: meta.pageIdx,
subPath: meta.subPath,
targetSubPath: targetSubPath,
crumbIcons: meta.crumbIcons,
crumbLabels: meta.crumbLabels,
trailKey: meta.crumbLabels.join("/"),
title: cleanLabel(label),
anchor: anchor,
section: section,
subtext: subtext && !/%\d/.test(subtext) ? subtext : "",
togglePath: togglePath,
});
}
}
}
return mergeInfoRows(entries);
}
function mergeInfoRows(entries) {
const out = [];
const merged = {};
for (const entry of entries) {
const isInfo = entry.rowType === "InfoRow";
delete entry.rowType;
if (!isInfo || !entry.section) {
out.push(entry);
continue;
}
const key =
entry.anchor.split("-")[0] +
"/" +
entry.trailKey +
"/" +
entry.section;
const existing = merged[key];
if (existing === undefined) {
entry.keywords = entry.title;
entry.title = entry.section;
merged[key] = entry;
out.push(entry);
} else {
existing.keywords += " " + entry.title;
}
}
return out;
}
function buildInvertedAndRanking(entries) {
const inverted = {};
const ranking = {};
for (let idx = 0; idx < entries.length; idx++) {
const e = entries[idx];
const extra =
e.crumbLabels.join(" ") +
" " +
e.section +
" " +
e.subtext +
" " +
(e.keywords ?? "");
const fields = {
title: e.title,
keywords: tokenize(e.title + " " + extra)
.sort()
.join(" "),
};
for (const field in fields) {
const weight = FIELD_WEIGHT[field] ?? 0.2;
for (const tok of tokenize(fields[field])) {
if (!inverted[tok]) inverted[tok] = [];
if (!inverted[tok].includes(idx)) inverted[tok].push(idx);
if (!ranking[tok]) ranking[tok] = {};
ranking[tok][idx] = Math.max(ranking[tok][idx] ?? 0.0, weight);
}
}
}
for (const tok in inverted)
inverted[tok].sort((a, b) => ranking[tok][b] - ranking[tok][a]);
return [inverted, ranking];
}
function buildIndex(settingsDir, readFile, listFiles) {
const lineCache = {};
const readLines = (path) => {
if (!(path in lineCache)) lineCache[path] = readFile(path).split("\n");
return lineCache[path];
};
const files = discoverFiles(settingsDir, listFiles);
const nav = buildNavMap(settingsDir, files, readFile, readLines);
const comps = parsePageComps(settingsDir, readFile);
const entries = extractSettings(files, nav, comps, readLines);
const [inverted, ranking] = buildInvertedAndRanking(entries);
return {
version: 3,
entries: entries,
inverted: inverted,
ranking: ranking,
};
}