Author SHA1 Message Date
zach a3178ad9cd Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Successful in 6s
C++ / build (pull_request) Failing after 9s
C++ / clang-tidy (pull_request) Failing after 9s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 8s
Python / static (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 1m3s
Python / verify (pull_request) Successful in 1m42s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 1m4s
2026-09-14 09:52:08 +02:00
zach 82ca5b5335 fix: properly convert vram mib -> kib & generic gpu vram reading 2026-09-13 22:25:58 +02:00
zach 02254a10d8 gitignore
C++ / fmt (pull_request) Successful in 5s
C++ / build (pull_request) Failing after 10s
C++ / clang-tidy (pull_request) Failing after 10s
JS/TS / fmt (pull_request) Failing after 11s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 38s
Rust / build (pull_request) Successful in 56s
Python / verify (pull_request) Successful in 1m45s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 1m0s
2026-09-12 23:58:19 +02:00
zach 54d4676ddf Merge branch 'main' into feat/llm-chat-tab
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 2m3s
C++ / clang-tidy (pull_request) Failing after 2m2s
JS/TS / fmt (pull_request) Failing after 12s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 51s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 33s
Rust / clippy (pull_request) Successful in 57s
2026-09-04 19:44:12 +02:00
zach bd699e72a7 fix: polkit width 2026-09-04 19:43:13 +02:00
zach cd3da1941b fix: truncate context logic
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 2m6s
C++ / clang-tidy (pull_request) Failing after 2m5s
JS/TS / fmt (pull_request) Failing after 3m8s
Python / static (pull_request) Successful in 24s
Python / verify (pull_request) Successful in 1m21s
JS/TS / lint (pull_request) Successful in 5m10s
Rust / fmt (pull_request) Successful in 30s
Rust / build (pull_request) Successful in 56s
Rust / clippy (pull_request) Successful in 46s
2026-09-04 02:20:37 +02:00
zach 34dde4bbfb fix: tool call prompt
C++ / fmt (pull_request) Failing after 4s
C++ / build (pull_request) Failing after 2m4s
C++ / clang-tidy (pull_request) Failing after 2m2s
JS/TS / fmt (pull_request) Failing after 6s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 27s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 27s
Rust / clippy (pull_request) Successful in 49s
2026-09-03 23:45:07 +02:00
zach fcb848b6d2 fix: truncate webfetch content to fit context
C++ / fmt (pull_request) Failing after 5s
C++ / build (pull_request) Failing after 2m18s
C++ / clang-tidy (pull_request) Failing after 2m7s
JS/TS / fmt (pull_request) Failing after 9s
JS/TS / lint (pull_request) Successful in 9s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 50s
Python / verify (pull_request) Successful in 1m38s
Rust / fmt (pull_request) Successful in 26s
Rust / clippy (pull_request) Successful in 47s
2026-09-03 22:46:46 +02:00
zach ed7ec326b1 fix: idlemonitors list value 2026-09-03 16:15:55 +02:00
zach ae5c1fa148 use blobgroup for window chat interface + slim sidebar
C++ / fmt (pull_request) Successful in 4s
C++ / build (pull_request) Failing after 2m1s
C++ / clang-tidy (pull_request) Failing after 1m54s
JS/TS / fmt (pull_request) Failing after 7s
JS/TS / lint (pull_request) Successful in 7s
Python / static (pull_request) Successful in 29s
Rust / build (pull_request) Successful in 47s
Python / verify (pull_request) Successful in 1m35s
Rust / fmt (pull_request) Successful in 29s
Rust / clippy (pull_request) Successful in 51s
2026-09-01 23:14:21 +02:00
zach b254000737 feat: implement findChild* methods
C++ / fmt (pull_request) Canceled after 0s
C++ / build (pull_request) Canceled after 0s
C++ / clang-tidy (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
JS/TS / fmt (pull_request) Canceled after 2s
Python / static (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
2026-09-01 15:51:10 +02:00
zach 04fe8b1e43 feat: implement findChild* methods
C++ / fmt (pull_request) Canceled after 0s
C++ / build (pull_request) Canceled after 0s
C++ / clang-tidy (pull_request) Canceled after 0s
JS/TS / fmt (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
Python / static (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
2026-09-01 15:49:21 +02:00
zach dfa6b010e6 fix: property link modules
C++ / fmt (pull_request) Canceled after 0s
C++ / build (pull_request) Canceled after 0s
C++ / clang-tidy (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
JS/TS / fmt (pull_request) Canceled after 1s
Python / static (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
2026-09-01 15:42:27 +02:00
zach 8a4e9bac31 fix: add gnuinstalldirs include
C++ / fmt (pull_request) Successful in 6s
C++ / build (pull_request) Canceled after 15s
C++ / clang-tidy (pull_request) Canceled after 9s
JS/TS / fmt (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
Python / static (pull_request) Canceled after 1s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 6s
2026-09-01 15:28:25 +02:00
zach f20fa45b29 fix: cmake install dir
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
Python / static (pull_request) Canceled after 0s
Python / verify (pull_request) Canceled after 0s
JS/TS / fmt (pull_request) Canceled after 0s
JS/TS / lint (pull_request) Canceled after 0s
C++ / fmt (pull_request) Canceled after 0s
C++ / build (pull_request) Canceled after 0s
C++ / clang-tidy (pull_request) Canceled after 0s
2026-09-01 15:15:15 +02:00
zach 53a3c76dc0 fix: cmake install dir
C++ / fmt (pull_request) Successful in 7s
JS/TS / fmt (pull_request) Failing after 14s
JS/TS / lint (pull_request) Successful in 18s
Python / static (pull_request) Successful in 57s
C++ / build (pull_request) Failing after 3m50s
C++ / clang-tidy (pull_request) Failing after 3m32s
Python / verify (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 0s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
2026-09-01 15:03:42 +02:00
zach 617e87d64f fix: uncomment settings index cache path
C++ / fmt (pull_request) Successful in 10s
C++ / clang-tidy (pull_request) Failing after 15m10s
C++ / build (pull_request) Failing after 16m4s
JS/TS / fmt (pull_request) Failing after 10s
JS/TS / lint (pull_request) Successful in 11s
Python / static (pull_request) Successful in 53s
Rust / fmt (pull_request) Successful in 1m33s
Rust / build (pull_request) Successful in 3m3s
Rust / clippy (pull_request) Successful in 2m59s
Python / verify (pull_request) Successful in 3m58s
2026-09-01 14:31:44 +02:00
zach ef2fb95fab chore: split cmake into multiple files and fix settings searching regex + add enabled option to llm
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Failing after 17s
JS/TS / lint (pull_request) Successful in 19s
Python / static (pull_request) Successful in 1m11s
Rust / fmt (pull_request) Successful in 1m3s
Rust / build (pull_request) Successful in 2m26s
Rust / clippy (pull_request) Failing after 16m40s
Python / verify (pull_request) Failing after 17m52s
C++ / clang-tidy (pull_request) Failing after 18m0s
C++ / build (pull_request) Failing after 18m2s
2026-09-01 14:30:04 +02:00
zach 2150859847 fix: font family
JS/TS / fmt (pull_request) Successful in 19s
JS/TS / lint (pull_request) Successful in 18s
Rust / fmt (pull_request) Canceled after 0s
Rust / clippy (pull_request) Canceled after 0s
Rust / build (pull_request) Canceled after 5m56s
Python / static (pull_request) Canceled after 6m44s
Python / verify (pull_request) Canceled after 6m40s
C++ / fmt (pull_request) Successful in 8s
C++ / clang-tidy (pull_request) Canceled after 7m8s
C++ / build (pull_request) Failing after 1m24s
2026-08-31 22:54:52 +02:00
zach 04869656aa Merge pull request 'changed old appearance vars for the lockscreen + warning hunting for the polkit' (#154) from fix/lockscreen-font into main
Reviewed-on: #154
Reviewed-by: AramJonghu <aramjonghu@tutamail.com>
Reviewed-by: zach <zach@brohn.se>
2026-08-31 00:14:06 +02:00
zach 2f6847ce0e Merge branch 'main' into fix/lockscreen-font
C++ / fmt (pull_request) Successful in 3s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 20s
Python / static (pull_request) Successful in 58s
Rust / fmt (pull_request) Successful in 55s
C++ / build (pull_request) Successful in 2m31s
Rust / build (pull_request) Successful in 2m16s
Rust / clippy (pull_request) Successful in 1m33s
Python / verify (pull_request) Successful in 2m53s
C++ / clang-tidy (pull_request) Successful in 4m5s
2026-08-31 00:13:16 +02:00
zach e5e52802de test aur 2026-08-30 23:49:53 +02:00
zach 42f02f4dd2 test aur 2026-08-30 23:33:50 +02:00
zach 9df3a64815 fix: lowercase battery device state 2026-08-30 22:20:39 +02:00
Inorishio 9370284c6b changed old appearance vars for the lockscreen + warning hunting for the polkit
C++ / fmt (pull_request) Successful in 4s
JS/TS / fmt (pull_request) Successful in 14s
JS/TS / lint (pull_request) Successful in 16s
Python / static (pull_request) Successful in 1m14s
Rust / fmt (pull_request) Successful in 1m8s
Rust / build (pull_request) Successful in 2m14s
C++ / build (pull_request) Successful in 2m36s
Rust / clippy (pull_request) Successful in 1m15s
Python / verify (pull_request) Successful in 3m19s
C++ / clang-tidy (pull_request) Successful in 4m1s
2026-08-30 16:35:20 +02:00
zach 5598f4f435 README.md 2026-08-23 17:01:27 +02:00
zach 7e02c87b91 README.md 2026-08-23 17:00:41 +02:00
65 changed files with 2827 additions and 963 deletions
+2
View File
@@ -18,3 +18,5 @@ dist/
network-dev/ network-dev/
**/zshell.build/ **/zshell.build/
**/zshell.dist/ **/zshell.dist/
.opencode/
run-agent.sh
+20 -20
View File
@@ -26,22 +26,36 @@ if(NOT DEFINED VERSION)
endif() endif()
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}") set(VERSION "${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}")
project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH}) project(ZShell VERSION ${VERSION_MAJOR}.${VERSION_MINOR}.${VERSION_PATCH})
message(STATUS "ZShell version: ${VERSION}") message(STATUS "ZShell version: ${VERSION}")
include(GNUInstallDirs)
set(CMAKE_CXX_STANDARD 20) set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON) set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF) set(CMAKE_CXX_EXTENSIONS OFF)
set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib") set(CMAKE_RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/lib")
set(CMAKE_EXPORT_COMPILE_COMMANDS ON) set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
set(ENABLE_MODULES "plugin;shell;m3shapes" CACHE STRING "Modules to build/install") set(ENABLE_MODULES "plugin;shell;cli;m3shapes" CACHE STRING "Modules to build/install")
set(INSTALL_LIBDIR "usr/lib/ZShell" CACHE STRING "Library install dir") set(INSTALL_LIBDIR "${CMAKE_INSTALL_LIBDIR}/ZShell" CACHE STRING "Library install dir")
set(INSTALL_QMLDIR "usr/lib/qt6/qml" CACHE STRING "QML install dir") set(INSTALL_QMLDIR "${CMAKE_INSTALL_LIBDIR}/qt6/qml" CACHE STRING "QML install dir")
set(INSTALL_QSCONFDIR "etc/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir") set(INSTALL_QSCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell" CACHE STRING "Quickshell config install dir")
set(INSTALL_GREETERCONFDIR "etc/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir") set(INSTALL_GREETERCONFDIR "${CMAKE_INSTALL_SYSCONFDIR}/xdg/quickshell/zshell-greeter" CACHE STRING "Quickshell greeter install dir")
set(CMAKE_INSTALL_MESSAGE NEVER) set(CMAKE_INSTALL_MESSAGE NEVER)
@@ -54,21 +68,7 @@ add_compile_options(
) )
if("shell" IN_LIST ENABLE_MODULES) if("cli" 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 # Nuitka compilation
set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli") set(ZSHELL_CLI_BUILD_DIR "${CMAKE_BINARY_DIR}/zshell-cli")
set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist") set(ZSHELL_CLI_DIST "${ZSHELL_CLI_BUILD_DIR}/zshell.dist")
+1 -2
View File
@@ -6,7 +6,6 @@ Rectangle {
color: "transparent" color: "transparent"
Behavior on color { 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 bool isHorizontal: flickable && flickable.ScrollBar.horizontal === root
readonly property real axisSize: isHorizontal ? flickable.width : flickable.height readonly property real axisSize: isHorizontal ? flickable.width : flickable.height
readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight readonly property real axisContentSize: isHorizontal ? flickable.contentWidth : flickable.contentHeight
readonly property real axisContentPos: isHorizontal ? (flickable.contentX - flickable.originX) : (flickable.contentY - flickable.originY) readonly property real axisContentPos: isHorizontal ? (reversed ? flickable.contentX : (flickable.contentX - flickable.originX)) : (reversed ? flickable.contentY : (flickable.contentY - flickable.originY))
readonly property real axisLength: isHorizontal ? root.width : root.height readonly property real axisLength: isHorizontal ? root.width : root.height
readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize) readonly property real effectiveSize: Math.max(nonAnimHeight, root.minimumSize)
readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize) readonly property real effectiveTravel: Math.max(0, 1 - root.effectiveSize)
@@ -102,9 +102,9 @@ ScrollBar {
var newPos = contentPosFromThumbStart(thumbStart); var newPos = contentPosFromThumbStart(thumbStart);
if (root.isHorizontal) if (root.isHorizontal)
root.flickable.contentX = newPos + root.flickable.originX; root.flickable.contentX = newPos + (root.flickable.originX + (root.reversed ? root.flickable.contentWidth : 0));
else else
root.flickable.contentY = newPos + root.flickable.originY; root.flickable.contentY = newPos + (root.flickable.originY + (root.reversed ? root.flickable.contentHeight : 0));
} }
function visualThumbStart() { function visualThumbStart() {
+1
View File
@@ -12,6 +12,7 @@ TextEdit {
color: Colors.palette.m3onSurface color: Colors.palette.m3onSurface
cursorVisible: !readOnly cursorVisible: !readOnly
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
font.family: Config.appearance.font.family.sans
renderType: TextField.NativeRendering renderType: TextField.NativeRendering
selectedTextColor: color selectedTextColor: color
selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4) selectionColor: Qt.alpha(Colors.palette.m3primary, 0.4)
@@ -40,7 +40,7 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
sourceComponent: BatteryIcon { sourceComponent: BatteryIcon {
devState: Battery.deviceStateString devState: Battery.deviceStateString.toLowerCase()
percentage: Battery.currentPerc percentage: Battery.currentPerc
} }
} }
+18 -1
View File
@@ -25,6 +25,8 @@ CustomListView {
return [0]; return [0];
case "variant": case "variant":
return SchemeVariants.query(text); return SchemeVariants.query(text);
case "files":
return Files.query(text);
default: default:
return Apps.search(text); return Apps.search(text);
} }
@@ -33,7 +35,7 @@ CustomListView {
function stateForText(text: string): string { function stateForText(text: string): string {
const prefix = Config.launcher.actionPrefix; const prefix = Config.launcher.actionPrefix;
if (text.startsWith(prefix)) { if (text.startsWith(prefix)) {
for (const action of ["calc", "scheme", "variant"]) for (const action of ["calc", "scheme", "variant", "files"])
if (text.startsWith(`${prefix}${action} `)) if (text.startsWith(`${prefix}${action} `))
return action; return action;
@@ -163,6 +165,13 @@ CustomListView {
PropertyChanges { PropertyChanges {
root.delegate: variantItem root.delegate: variantItem
} }
},
State {
name: "files"
PropertyChanges {
root.delegate: filesItem
}
} }
] ]
transitions: Transition { transitions: Transition {
@@ -264,6 +273,14 @@ CustomListView {
} }
} }
Component {
id: filesItem
FilesItem {
list: root
}
}
Connections { Connections {
function onTextChanged() { function onTextChanged() {
root.syncDisplayText(); root.syncDisplayText();
+2
View File
@@ -85,6 +85,8 @@ Item {
} else if (text.startsWith(Config.launcher.actionPrefix)) { } else if (text.startsWith(Config.launcher.actionPrefix)) {
if (text.startsWith(`${Config.launcher.actionPrefix}calc `)) if (text.startsWith(`${Config.launcher.actionPrefix}calc `))
currentItem.onClicked(); currentItem.onClicked();
else if (text.startsWith(`${Config.launcher.actionPrefix}files `))
currentItem.onClicked();
else else
currentItem.modelData.onClicked(list.currentList); currentItem.modelData.onClicked(list.currentList);
} else { } else {
+1 -1
View File
@@ -16,7 +16,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight implicitHeight: Config.launcher.sizes.itemHeight
StateLayer { StateLayer {
radius: Tokens.rounding.small radius: Tokens.rounding.medium
onClicked: { onClicked: {
root.modelData?.onClicked(root.list); root.modelData?.onClicked(root.list);
+1 -1
View File
@@ -19,7 +19,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight implicitHeight: Config.launcher.sizes.itemHeight
StateLayer { StateLayer {
radius: Tokens.rounding.small radius: Tokens.rounding.medium
onClicked: { onClicked: {
Apps.launch(root.modelData); Apps.launch(root.modelData);
+2 -3
View File
@@ -24,7 +24,7 @@ Item {
implicitHeight: Config.launcher.sizes.itemHeight implicitHeight: Config.launcher.sizes.itemHeight
StateLayer { StateLayer {
radius: Tokens.rounding.small radius: Tokens.rounding.medium
onClicked: { onClicked: {
root.onClicked(); root.onClicked();
@@ -97,8 +97,7 @@ Item {
text: qsTr("Open in calculator") text: qsTr("Open in calculator")
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
} }
+141
View File
@@ -0,0 +1,141 @@
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 implicitHeight: Config.launcher.sizes.itemHeight
StateLayer { StateLayer {
radius: Tokens.rounding.small radius: Tokens.rounding.medium
onClicked: { onClicked: {
root.modelData?.onClicked(root.list); root.modelData?.onClicked(root.list);
+194
View File
@@ -0,0 +1,194 @@
// 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 Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3secondary color: Colors.palette.m3secondary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: Time.hourStr text: Time.hourStr
} }
@@ -38,7 +38,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3primary color: Colors.palette.m3primary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: ":" text: ":"
} }
@@ -47,7 +47,7 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter Layout.alignment: Qt.AlignVCenter
color: Colors.palette.m3secondary color: Colors.palette.m3secondary
font.bold: true font.bold: true
font.family: Appearance.font.family.clock font.family: Config.appearance.font.family.clock
font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * 3 * root.centerScale)
text: Time.minuteStr text: Time.minuteStr
} }
@@ -58,7 +58,7 @@ ColumnLayout {
Layout.topMargin: -Tokens.padding.large * 2 Layout.topMargin: -Tokens.padding.large * 2
color: Colors.palette.m3tertiary color: Colors.palette.m3tertiary
font.bold: true font.bold: true
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale) font.pointSize: Math.floor(Tokens.font.size.extraLarge * root.centerScale)
text: Time.format("dddd, d MMMM yyyy") text: Time.format("dddd, d MMMM yyyy")
} }
@@ -226,7 +226,7 @@ ColumnLayout {
anchors.right: parent.right anchors.right: parent.right
animateProp: "opacity" animateProp: "opacity"
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
lineHeight: 1.2 lineHeight: 1.2
opacity: shouldBeVisible && !message.msg ? 1 : 0 opacity: shouldBeVisible && !message.msg ? 1 : 0
@@ -295,7 +295,7 @@ ColumnLayout {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
color: Colors.palette.m3error color: Colors.palette.m3error
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
horizontalAlignment: Qt.AlignHCenter horizontalAlignment: Qt.AlignHCenter
opacity: 0 opacity: 0
+5 -4
View File
@@ -2,6 +2,7 @@ pragma ComponentBehavior: Bound
import Quickshell import Quickshell
import Quickshell.Wayland import Quickshell.Wayland
import QtQuick
import ZShell.Internal import ZShell.Internal
import ZShell.Config import ZShell.Config
import qs.Helpers import qs.Helpers
@@ -30,12 +31,12 @@ Scope {
Quickshell.execDetached(action); Quickshell.execDetached(action);
} }
LidWatcher { LidWatcher {
onAboutToSleep: root.lock.lock.locked = true onAboutToSleep: root.lock.lock.locked = true
} }
Variants { Variants {
model: Config.general.idle.timeouts model: Config.general.idle.timeouts.values
IdleMonitor { IdleMonitor {
required property var modelData required property var modelData
+1 -1
View File
@@ -41,7 +41,7 @@ Item {
anchors.centerIn: parent anchors.centerIn: parent
animate: true animate: true
color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline color: root.pam.passwd.active ? Colors.palette.m3secondary : Colors.palette.m3outline
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.normal font.pointSize: Tokens.font.size.normal
opacity: root.buffer ? 0 : 1 opacity: root.buffer ? 0 : 1
text: { text: {
+2 -2
View File
@@ -24,7 +24,7 @@ ColumnLayout {
Layout.fillWidth: true Layout.fillWidth: true
color: Colors.palette.m3outline color: Colors.palette.m3outline
elide: Text.ElideRight elide: Text.ElideRight
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.weight: 500 font.weight: 500
text: NotifServer.list.length > 0 ? qsTr("%1 notification%2").arg(NotifServer.list.length).arg(NotifServer.list.length === 1 ? "" : "s") : qsTr("Notifications") 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 { CustomText {
Layout.alignment: Qt.AlignHCenter Layout.alignment: Qt.AlignHCenter
color: Colors.palette.m3outlineVariant color: Colors.palette.m3outlineVariant
font.family: Appearance.font.family.mono font.family: Config.appearance.font.family.mono
font.pointSize: Tokens.font.size.large font.pointSize: Tokens.font.size.large
font.weight: 500 font.weight: 500
text: qsTr("No Notifications") text: qsTr("No Notifications")
@@ -198,6 +198,7 @@ Item {
} }
delegate: MessageDelegate { delegate: MessageDelegate {
rotation: 180 rotation: 180
rootParent: list
} }
displaced: Transition { displaced: Transition {
@@ -11,6 +11,7 @@ CustomClippingRect {
property bool expanded: false property bool expanded: false
property bool highlighted: false property bool highlighted: false
property bool slim: false
required property ChatSession modelData required property ChatSession modelData
signal clicked(content: ChatSession) signal clicked(content: ChatSession)
@@ -20,6 +21,9 @@ CustomClippingRect {
implicitHeight: { implicitHeight: {
let h = 0; let h = 0;
if (slim)
return chatIcon.implicitHeight + chatIcon.anchors.topMargin * 2;
h += infoContainer.implicitHeight; h += infoContainer.implicitHeight;
if (expanded) if (expanded)
@@ -85,6 +89,14 @@ CustomClippingRect {
return h; return h;
} }
opacity: root.slim ? 0 : 1
Behavior on opacity {
Anim {
type: Anim.DefaultEffects
}
}
CustomRect { CustomRect {
id: title id: title
@@ -246,7 +258,7 @@ CustomClippingRect {
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
maximumLineCount: 1 maximumLineCount: 1
opacity: !root.expanded ? 1 : 0 opacity: !root.expanded && !root.slim ? 1 : 0
text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet") text: root.modelData.messagesModel.lastMessage?.activeGeneration.content.replace(/\s+/g, " ").trim() ?? qsTr("No messages yet")
Behavior on y { Behavior on y {
@@ -347,6 +359,16 @@ CustomClippingRect {
radius: Tokens.rounding.full radius: Tokens.rounding.full
color: Colors.layer(Colors.palette.m3surfaceContainerHighest, 3) 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 { StateLayer {
onClicked: root.expanded = !root.expanded onClicked: root.expanded = !root.expanded
} }
@@ -14,7 +14,9 @@ Item {
property alias list: list property alias list: list
property alias model: list.model property alias model: list.model
property bool highlight: false property bool highlight: false
property bool slim: false
signal requestExpand
signal deleteChatRequest(content: ChatSession) signal deleteChatRequest(content: ChatSession)
signal loadChatRequest(content: ChatSession, index: int) signal loadChatRequest(content: ChatSession, index: int)
signal newChatRequest signal newChatRequest
@@ -79,6 +81,7 @@ Item {
implicitWidth: ListView.view.width implicitWidth: ListView.view.width
highlighted: root.highlight && ChatState.chatSession === modelData highlighted: root.highlight && ChatState.chatSession === modelData
slim: root.slim
onHighlightedChanged: if (highlighted) onHighlightedChanged: if (highlighted)
list.currentIndex = index list.currentIndex = index
@@ -110,8 +113,15 @@ Item {
radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2 radius: modelLayer.pressed ? Tokens.rounding.small : modelName.implicitHeight / 2
function prettyModelName(): string { function prettyModelName(): string {
if (modelsList.count < 1)
return qsTr("No models found");
const pathList = Chat.model.split("\/"); const pathList = Chat.model.split("\/");
const name = pathList[pathList.length - 1]; const name = pathList[pathList.length - 1];
if (!name)
return qsTr("No model selected");
return name; return name;
} }
@@ -131,6 +141,7 @@ Item {
implicitHeight: fabRoot.implicitHeight implicitHeight: fabRoot.implicitHeight
CustomText { CustomText {
id: label
text: modelsContainer.prettyModelName() text: modelsContainer.prettyModelName()
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
anchors.left: parent.left anchors.left: parent.left
@@ -146,10 +157,13 @@ Item {
text: modelsContainer.expanded ? "unfold_less" : "unfold_more" text: modelsContainer.expanded ? "unfold_less" : "unfold_more"
animate: true animate: true
font.pointSize: Tokens.font.size.large font.pointSize: Tokens.font.size.large
visible: modelsList.count > 0
} }
StateLayer { StateLayer {
id: modelLayer id: modelLayer
enabled: modelsList.count > 0
onClicked: { onClicked: {
ChatState.fabExpanded = false; ChatState.fabExpanded = false;
modelsContainer.expanded = !modelsContainer.expanded; modelsContainer.expanded = !modelsContainer.expanded;
@@ -246,8 +260,16 @@ Item {
anchors.margins: Tokens.padding.small anchors.margins: Tokens.padding.small
padding: 8 padding: 8
font.pointSize: Math.round(18 * 1.2) font.pointSize: Math.round(18 * 1.2)
icon: "add" icon: root.slim ? "left_panel_open" : "add"
isRound: ChatState.fabExpanded isRound: ChatState.fabExpanded
// opacity: root.slim ? 0 : 1
// visible: opacity > 0
//
// Behavior on opacity {
// Anim {
// type: Anim.DefaultEffects
// }
// }
label.transform: Rotation { label.transform: Rotation {
origin.y: fabRoot.label.height / 2 origin.y: fabRoot.label.height / 2
@@ -260,6 +282,11 @@ Item {
} }
onClicked: { onClicked: {
if (root.slim && !ChatState.narrowSidebarExpanded) {
root.requestExpand();
return;
}
modelsContainer.expanded = false; modelsContainer.expanded = false;
ChatState.fabExpanded = !ChatState.fabExpanded; ChatState.fabExpanded = !ChatState.fabExpanded;
} }
@@ -14,6 +14,11 @@ Singleton {
property bool inChat: false property bool inChat: false
property bool fabExpanded: false property bool fabExpanded: false
property bool isWindow: 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 ShellScreen screen
property bool sidebarVisible: Visibilities.getForActive().sidebar property bool sidebarVisible: Visibilities.getForActive().sidebar
@@ -9,7 +9,6 @@ TextEditBase {
color: Colors.palette.m3onSurface color: Colors.palette.m3onSurface
readOnly: true readOnly: true
anchors.margins: Tokens.padding.medium
textFormat: Text.MarkdownText textFormat: Text.MarkdownText
font.pointSize: Tokens.font.size.smaller font.pointSize: Tokens.font.size.smaller
wrapMode: Text.WrapAtWordBoundaryOrAnywhere wrapMode: Text.WrapAtWordBoundaryOrAnywhere
@@ -6,127 +6,138 @@ import qs.Services
import qs.Modules.Notifications.Sidebar.Chat import qs.Modules.Notifications.Sidebar.Chat
Item { Item {
id: root id: root
property int animOff property int animOff
property Item currentItem property Item currentItem
property bool loading: false property bool noAnim: false
property int lastIdx: -1 property bool loading: false
property int lastIdx: -1
readonly property Component conversationComp: ChatContent {} readonly property Component conversationComp: ChatContent {}
function loadConversation(chat): void { signal requestClose
if (currentItem) {
currentItem.destroy();
currentItem = null;
}
if (!chat) function loadConversation(chat): void {
return; if (currentItem) {
currentItem.destroy();
currentItem = null;
}
root.loading = true; if (!chat)
return;
const incubator = root.conversationComp.incubateObject(container, { root.loading = true;
chatData: chat
});
const attach = () => { const incubator = root.conversationComp.incubateObject(container, {
incubator.object.anchors.fill = container; chatData: chat
currentItem = incubator.object; });
root.loading = false;
enterAnim.start();
};
if (incubator.status === Component.Ready) const attach = () => {
attach(); incubator.object.anchors.fill = container;
else incubator.object.requestClose.connect(root.requestClose);
incubator.onStatusChanged = status => { currentItem = incubator.object;
if (status === Component.Ready) root.loading = false;
attach(); if (!noAnim)
}; enterAnim.start();
} };
Item { if (incubator.status === Component.Ready)
id: container attach();
else
incubator.onStatusChanged = status => {
if (status === Component.Ready)
attach();
};
}
anchors.fill: parent Item {
layer.enabled: opacity < 1 id: container
objectName: "ConversationContainer"
Component.onCompleted: { anchors.fill: parent
if (ChatState.chatSession) layer.enabled: opacity < 1
root.loadConversation(ChatState.chatSession); objectName: "ConversationContainer"
}
}
LoadingIndicator { Component.onCompleted: {
anchors.centerIn: parent if (ChatState.chatSession)
implicitSize: Tokens.font.size.extraLarge * 4 root.loadConversation(ChatState.chatSession);
opacity: root.loading ? 1 : 0 }
visible: opacity > 0 }
Behavior on opacity { LoadingIndicator {
Anim {} anchors.centerIn: parent
} implicitSize: Tokens.font.size.extraLarge * 4
} opacity: root.loading ? 1 : 0
visible: opacity > 0
Connections { Behavior on opacity {
function onChatSessionChanged(): void { Anim {}
exitAnim.complete(); }
enterAnim.complete(); }
root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1);
root.lastIdx = ChatState.currentIdx;
exitAnim.start();
}
target: ChatState Connections {
} function onChatSessionChanged(): void {
exitAnim.complete();
enterAnim.complete();
SequentialAnimation { if (root.noAnim) {
id: exitAnim root.loadConversation(ChatState.chatSession);
return;
}
Anim { root.animOff = Tokens.padding.small * (ChatState.currentIdx > root.lastIdx ? 1 : -1);
property: "opacity" root.lastIdx = ChatState.currentIdx;
target: container exitAnim.start();
to: 0 }
type: Anim.DefaultEffects
}
ScriptAction { target: ChatState
script: root.loadConversation(ChatState.chatSession) }
}
}
SequentialAnimation { SequentialAnimation {
id: enterAnim id: exitAnim
PropertyAction { Anim {
property: "topMargin" property: "opacity"
target: container.anchors target: container
value: root.animOff to: 0
} type: Anim.DefaultEffects
}
PropertyAction { ScriptAction {
property: "bottomMargin" script: root.loadConversation(ChatState.chatSession)
target: container.anchors }
value: -root.animOff }
}
ParallelAnimation { SequentialAnimation {
Anim { id: enterAnim
from: 0
property: "opacity"
target: container
to: 1
type: Anim.SlowEffects
}
Anim { PropertyAction {
properties: "topMargin,bottomMargin" property: "topMargin"
target: container.anchors target: container.anchors
to: 0 value: root.animOff
type: Anim.SlowEffects }
}
} PropertyAction {
} property: "bottomMargin"
target: container.anchors
value: -root.animOff
}
ParallelAnimation {
Anim {
from: 0
property: "opacity"
target: container
to: 1
type: Anim.SlowEffects
}
Anim {
properties: "topMargin,bottomMargin"
target: container.anchors
to: 0
type: Anim.SlowEffects
}
}
}
} }
@@ -2,6 +2,7 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Llm import ZShell.Llm
import ZShell.Config import ZShell.Config
import qs.Modules.Notifications.Sidebar.Chat
import qs.Components import qs.Components
import qs.Services import qs.Services
@@ -16,32 +17,60 @@ Item {
required property ChatMessage message required property ChatMessage message
required property ChatGeneration current 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 readonly property real contentMaxWidth: width - Tokens.spacing.extraSmall - Tokens.spacing.extraLarge
signal edit(text: string) signal edit(text: string)
implicitHeight: bubble.implicitHeight + actionsRow.implicitHeight + actionsRow.anchors.topMargin implicitHeight: bubble.implicitHeight + (actionsRow.shouldBeActive ? actionsRow.implicitHeight + actionsRow.anchors.topMargin : 0)
CustomClippingRect { CustomClippingRect {
id: bubble 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 radius: Tokens.rounding.medium
color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer color: root.isUser ? Colors.palette.m3primary : Colors.palette.m3surfaceContainer
implicitWidth: root.isUser ? Math.min(msgText.implicitWidth + Tokens.padding.medium * 2, root.contentMaxWidth) : root.contentMaxWidth implicitWidth: layoutFrozen ? frozenWidth : layoutWidth
implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2 implicitHeight: root.isUser ? msgText.contentHeight + Tokens.padding.medium * 2 : blocks.implicitHeight + blocks.anchors.topMargin * 2
anchors.right: root.isUser ? parent.right : undefined anchors.right: root.isUser ? parent.right : undefined
layer.enabled: false
layer.smooth: true
// Behavior on implicitHeight { transform: Scale {
// enabled: !root.segment.running id: stretchScale
// origin.x: root.isUser ? bubble.width : 0
// Anim { xScale: bubble.layoutFrozen && bubble.frozenWidth > 0 ? bubble.liveTargetWidth / bubble.frozenWidth : 1.0
// type: Anim.DefaultEffects }
// }
// } 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;
}
// User messages stay a plain editable text field.
TextEditBase { TextEditBase {
id: msgText id: msgText
@@ -14,6 +14,7 @@ MouseArea {
property ChatGeneration current: modelData.activeGeneration property ChatGeneration current: modelData.activeGeneration
required property int index required property int index
readonly property bool isUser: modelData.role === ChatMessage.Role.User readonly property bool isUser: modelData.role === ChatMessage.Role.User
required property Item rootParent
required property ChatMessage modelData required property ChatMessage modelData
property bool reasoningExpanded: false property bool reasoningExpanded: false
readonly property var blocks: blockify(current.segments) readonly property var blocks: blockify(current.segments)
@@ -160,6 +161,7 @@ MouseArea {
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
spacing: Tokens.spacing.medium
Repeater { Repeater {
id: segmentRep id: segmentRep
@@ -177,6 +179,8 @@ MouseArea {
delegate: ProcessBlock { delegate: ProcessBlock {
width: root.width width: root.width
blocks: root.blocks blocks: root.blocks
current: root.current
rootParent: root.rootParent
onExpandedChanged: root.handleReasoningToggle(expanded) onExpandedChanged: root.handleReasoningToggle(expanded)
} }
@@ -1,23 +1,40 @@
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import ZShell.Blobs
import ZShell.Config import ZShell.Config
import ZShell.Components
import ZShell.Llm import ZShell.Llm
import qs.Components import qs.Components
import qs.Services import qs.Services
CustomClippingRect { Item {
id: root id: root
required property int index required property int index
required property var modelData required property var modelData
required property var blocks required property var blocks
readonly property var segments: modelData.segments readonly property var segments: modelData.segments
required property Item rootParent
required property ChatGeneration current
readonly property bool isActive: index === blocks.length - 1 readonly property bool isActive: index === blocks.length - 1
property bool expanded: false property bool expanded: false
readonly property LlmSegment lastSegment: root.segments[root.segments.length - 1] 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 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))
implicitHeight: expanded ? expandedRect.implicitHeight + layout.implicitHeight + expandedRect.anchors.topMargin * 2 : layout.implicitHeight + Tokens.spacing.small 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;
}
Behavior on implicitHeight { Behavior on implicitHeight {
Anim { Anim {
@@ -57,6 +74,7 @@ CustomClippingRect {
inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline inactiveOnColor: hovered ? Colors.palette.m3onSurface : Colors.palette.m3outline
anchors.centerIn: parent anchors.centerIn: parent
opacity: root.isActive ? 0 : 1 opacity: root.isActive ? 0 : 1
enabled: opacity > 0
rotation: root.expanded ? 180 : 0 rotation: root.expanded ? 180 : 0
type: IconButton.Text type: IconButton.Text
@@ -77,6 +95,9 @@ CustomClippingRect {
Layout.leftMargin: Tokens.spacing.medium Layout.leftMargin: Tokens.spacing.medium
text: { text: {
if (root.isActive) { if (root.isActive) {
if (root.pendingApproval)
return qsTr("Waiting for approval...");
if (root.lastSegment.type === LlmSegment.Type.ToolCall) if (root.lastSegment.type === LlmSegment.Type.ToolCall)
return qsTr("Using %1...").arg(root.lastSegment.name); return qsTr("Using %1...").arg(root.lastSegment.name);
return qsTr("Thinking..."); return qsTr("Thinking...");
@@ -90,6 +111,261 @@ CustomClippingRect {
} }
} }
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 { CustomRect {
id: expandedRect id: expandedRect
@@ -98,7 +374,6 @@ CustomClippingRect {
anchors.top: layout.bottom anchors.top: layout.bottom
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.topMargin: Tokens.spacing.small anchors.topMargin: Tokens.spacing.small
anchors.bottomMargin: Tokens.spacing.small
color: Colors.palette.m3surfaceContainerLow color: Colors.palette.m3surfaceContainerLow
implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2 implicitHeight: expandedContent.contentHeight + expandedContent.anchors.margins * 2
opacity: root.expanded ? 1 : 0 opacity: root.expanded ? 1 : 0
@@ -147,6 +422,49 @@ CustomClippingRect {
CustomText { CustomText {
id: content 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.left: parent.left
anchors.leftMargin: Tokens.padding.small anchors.leftMargin: Tokens.padding.small
anchors.top: header.bottom anchors.top: header.bottom
@@ -155,7 +473,7 @@ CustomClippingRect {
color: Colors.palette.m3outline color: Colors.palette.m3outline
font.pointSize: Tokens.font.size.small font.pointSize: Tokens.font.size.small
wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere wrapMode: CustomText.WrapAtWordBoundaryOrAnywhere
text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("Fetched %1").arg(JSON.parse(segment.modelData.arguments)?.url ?? "website") text: segment.modelData.type === LlmSegment.Type.Reasoning ? segment.modelData.text : qsTr("%1 %2").arg(content.prettyTool(segment.modelData)).arg(content.toolTarget(segment.modelData))
} }
} }
} }
@@ -4,38 +4,67 @@ import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import QtQuick.Controls import QtQuick.Controls
import Quickshell import Quickshell
import ZShell.Blobs
import ZShell.Config import ZShell.Config
import ZShell.Llm import ZShell.Llm
import qs.Components import qs.Components
import qs.Modules.Notifications.Sidebar.Chat.Content import qs.Modules.Notifications.Sidebar.Chat.Content
import qs.Services import qs.Services
RowLayout { Item {
id: root id: root
property int breakpoint: 700 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 alias conversationModel: sidebar.model property alias conversationModel: sidebar.model
property bool chatOpen: ChatState.chatSession !== null
readonly property bool isWide: root.width >= root.breakpoint readonly property bool isWide: root.width >= root.breakpoint
property bool narrowShowsSidebar: true
readonly property bool showList: isWide || !chatOpen
readonly property bool showContent: !isWide && chatOpen
function openConversation(conv: ChatSession, index: int): void { 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 { ChatList {
id: sidebar id: sidebar
Layout.fillHeight: true anchors.top: parent.top
Layout.margins: Tokens.padding.medium anchors.left: parent.left
Layout.rightMargin: Tokens.spacing.medium anchors.bottom: parent.bottom
Layout.maximumWidth: Config.sidebar.sizes.width anchors.margins: Tokens.padding.medium
Layout.preferredWidth: root.width / 4 implicitWidth: root.isWide || ChatState.narrowSidebarExpanded ? Config.sidebar.sizes.width : root.iconSize
Layout.minimumWidth: Config.sidebar.sizes.width / 2
highlight: true highlight: true
slim: !root.isWide && !ChatState.narrowSidebarExpanded
z: 1
Behavior on implicitWidth { Behavior on implicitWidth {
Anim {} Anim {
type: Anim.DefaultEffects
}
} }
model: ScriptModel { model: ScriptModel {
values: Chat.chats.values values: Chat.chats.values
} }
@@ -46,24 +75,78 @@ RowLayout {
} }
onDeleteChatRequest: chat => { onDeleteChatRequest: chat => {
if (chat === ChatState.chatSession) {
ChatState.chatSession = null;
ChatState.currentIdx = -1;
}
Chat.chats.remove(chat); Chat.chats.remove(chat);
ChatState.currentIdx = -1;
ChatState.chatSession = null;
} }
onNewChatRequest: { onNewChatRequest: {
const data = Chat.chats.insert(); const data = Chat.chats.insert();
ChatState.currentIdx = 0; ChatState.currentIdx = 0;
ChatState.chatSession = data; 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 { Item {
Layout.margins: Tokens.padding.extraLarge id: contentArea
Layout.leftMargin: Tokens.spacing.extraLarge
Layout.topMargin: Tokens.padding.large property int leftMargin: sidebar.implicitWidth
Layout.preferredWidth: Config.sidebar.sizes.width * 2
Layout.fillWidth: true z: 0
Layout.fillHeight: true 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"
}
}
ChatHost { ChatHost {
id: convHost id: convHost
@@ -71,10 +154,11 @@ RowLayout {
anchors.top: parent.top anchors.top: parent.top
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter anchors.horizontalCenter: parent.horizontalCenter
implicitWidth: Math.min(parent.width, 800) implicitWidth: Math.max(Math.min(parent.width, 800), Config.sidebar.sizes.width)
clip: true clip: true
onImplicitWidthChanged: console.log(implicitWidth) onLoadingChanged: if (!loading)
root.chatOpen = true
} }
ColumnLayout { ColumnLayout {
@@ -100,6 +184,51 @@ RowLayout {
color: Colors.tPalette.m3onSurfaceVariant 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 { Component {
+28 -25
View File
@@ -1,7 +1,6 @@
pragma ComponentBehavior: Bound pragma ComponentBehavior: Bound
import ZShell.Config import ZShell.Config
import ZShell.Llm
import QtQuick import QtQuick
import QtQuick.Layouts import QtQuick.Layouts
import qs.Components import qs.Components
@@ -31,7 +30,7 @@ Item {
Tabs { Tabs {
Layout.fillWidth: true Layout.fillWidth: true
Layout.preferredHeight: ChatState.isWindow ? 0 : implicitHeight Layout.preferredHeight: ChatState.isWindow || !Config.llm.enabled ? 0 : implicitHeight
dashState: root.props dashState: root.props
nonAnimWidth: layout.width nonAnimWidth: layout.width
visible: height > 0 visible: height > 0
@@ -53,12 +52,10 @@ Item {
x: root.props.currentTab === 0 ? 0 : -root.width x: root.props.currentTab === 0 ? 0 : -root.width
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
Behavior on x { Behavior on x {
Anim { Anim {}
}
} }
CustomRect { CustomRect {
@@ -73,33 +70,39 @@ Item {
} }
} }
Item { Loader {
id: chatPage id: chatLoader
anchors.bottom: parent.bottom anchors.bottom: parent.bottom
anchors.top: parent.top anchors.top: parent.top
implicitWidth: parent.width active: Config.llm.enabled
opacity: root.props.currentTab === 1 ? 1 : 0 width: parent.width
visible: opacity > 0 property bool tre: true
x: root.props.currentTab === 0 ? root.width : 0
z: 1
Behavior on opacity { sourceComponent: Item {
Anim { id: chatPage
opacity: root.props.currentTab === 1 ? 1 : 0
visible: opacity > 0
x: root.props.currentTab === 0 ? root.width : 0
z: 1
Behavior on opacity {
Anim {}
} }
}
Behavior on x { Behavior on x {
Anim { Anim {}
} }
}
CustomRect { CustomRect {
anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
ChatPanel {
anchors.fill: parent anchors.fill: parent
color: Colors.tPalette.m3surfaceContainerLow
radius: Tokens.rounding.small
ChatPanel {
anchors.fill: parent
}
} }
} }
} }
@@ -110,7 +110,10 @@ ColumnLayout {
onClicked: { onClicked: {
root.visibilities.sidebar = false; root.visibilities.sidebar = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]); if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.playback, recording.modelData.path]);
else
Quickshell.execDetached([...Config.general.apps.playback, recording.modelData.path]);
} }
} }
@@ -120,7 +123,10 @@ ColumnLayout {
onClicked: { onClicked: {
root.visibilities.sidebar = false; root.visibilities.sidebar = false;
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]); if (Config.launcher.uwsm)
Quickshell.execDetached(["app2unit", "--", ...Config.general.apps.explorer, recording.modelData.path]);
else
Quickshell.execDetached([...Config.general.apps.explorer, recording.modelData.path]);
} }
} }
} }
@@ -135,8 +141,7 @@ ColumnLayout {
} }
} }
Behavior on implicitHeight { Behavior on implicitHeight {
Anim { Anim {}
}
} }
model: FileSystemModel { model: FileSystemModel {
nameFilters: ["recording_*.mp4"] nameFilters: ["recording_*.mp4"]
@@ -162,8 +167,7 @@ ColumnLayout {
opacity: list.count === 0 ? 1 : 0 opacity: list.count === 0 ? 1 : 0
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
sourceComponent: ColumnLayout { sourceComponent: ColumnLayout {
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
@@ -178,16 +182,13 @@ ColumnLayout {
text: "scan_delete" text: "scan_delete"
Behavior on Layout.preferredHeight { Behavior on Layout.preferredHeight {
Anim { Anim {}
}
} }
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
Behavior on scale { Behavior on scale {
Anim { Anim {}
}
} }
} }
@@ -203,16 +204,13 @@ ColumnLayout {
text: "scan_delete" text: "scan_delete"
Behavior on Layout.preferredWidth { Behavior on Layout.preferredWidth {
Anim { Anim {}
}
} }
Behavior on opacity { Behavior on opacity {
Anim { Anim {}
}
} }
Behavior on scale { Behavior on scale {
Anim { Anim {}
}
} }
} }
+17 -20
View File
@@ -73,20 +73,21 @@ Scope {
// mask: Region { item: inputPanel } // mask: Region { item: inputPanel }
Rectangle { CustomRect {
id: inputPanel id: inputPanel
anchors.centerIn: parent anchors.centerIn: parent
color: Colors.tPalette.m3surface color: Colors.tPalette.m3surface
implicitHeight: layout.childrenRect.height + 28 implicitHeight: layout.implicitHeight + layout.anchors.margins * 2
implicitWidth: layout.childrenRect.width + 32 implicitWidth: Math.max(layout.implicitWidth + layout.anchors.margins * 2, 450)
opacity: 0 opacity: 0
radius: Tokens.rounding.small * 2 radius: Tokens.rounding.small * 2
ColumnLayout { ColumnLayout {
id: layout id: layout
anchors.centerIn: parent anchors.fill: parent
anchors.margins: Tokens.padding.medium
RowLayout { RowLayout {
id: contentRow id: contentRow
@@ -130,7 +131,7 @@ Scope {
Layout.preferredWidth: Math.min(600, contentWidth) Layout.preferredWidth: Math.min(600, contentWidth)
font.bold: true font.bold: true
font.pointSize: 16 font.pointSize: 16
text: polkitAgent.flow?.message text: polkitAgent.flow?.message ?? ""
wrapMode: Text.WordWrap wrapMode: Text.WordWrap
} }
@@ -147,8 +148,8 @@ Scope {
TextField { TextField {
id: passInput id: passInput
Layout.fillWidth: true
Layout.preferredHeight: 40 Layout.preferredHeight: 40
Layout.preferredWidth: contentColumn.implicitWidth
color: Colors.palette.m3onSurfaceVariant color: Colors.palette.m3onSurfaceVariant
echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password echoMode: polkitAgent.flow?.responseVisible ? TextInput.Normal : TextInput.Password
placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password" placeholderText: polkitAgent.flow?.failed ? " Incorrect Password" : " Input Password"
@@ -168,7 +169,7 @@ Scope {
id: showPassCheckbox id: showPassCheckbox
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
checked: polkitAgent.flow?.responseVisible checked: polkitAgent.flow?.responseVisible ?? false
text: "Show Password" text: "Show Password"
onCheckedChanged: { onCheckedChanged: {
@@ -189,7 +190,8 @@ Scope {
clip: true clip: true
color: Colors.tPalette.m3surfaceContainerLow color: Colors.tPalette.m3surfaceContainerLow
implicitHeight: 0 implicitHeight: 0
radius: 16 implicitWidth: textDetailsColumn.implicitWidth + textDetailsColumn.anchors.margins * 2
radius: Tokens.rounding.medium
visible: true visible: true
Behavior on open { Behavior on open {
@@ -197,7 +199,8 @@ Scope {
Anim { Anim {
property: "implicitHeight" property: "implicitHeight"
target: detailsPanel target: detailsPanel
to: !detailsPanel.open ? textDetailsColumn.childrenRect.height + 16 : 0 to: !detailsPanel.open ? textDetailsColumn.implicitHeight + Tokens.padding.small * 2 : 0
type: Anim.DefaultEffects
} }
Anim { Anim {
@@ -205,12 +208,6 @@ Scope {
target: textDetailsColumn target: textDetailsColumn
to: !detailsPanel.open ? 1 : 0 to: !detailsPanel.open ? 1 : 0
} }
Anim {
property: "scale"
target: textDetailsColumn
to: !detailsPanel.open ? 1 : 0.9
}
} }
} }
@@ -218,10 +215,9 @@ Scope {
id: textDetailsColumn id: textDetailsColumn
anchors.fill: parent anchors.fill: parent
anchors.margins: 8 anchors.margins: Tokens.padding.small
opacity: 0 opacity: 0
scale: 0.9 spacing: Tokens.spacing.small
spacing: 8
CustomText { CustomText {
text: `actionId: ${polkitAgent.flow?.actionId}` text: `actionId: ${polkitAgent.flow?.actionId}`
@@ -239,16 +235,17 @@ Scope {
Layout.preferredWidth: contentRow.implicitWidth Layout.preferredWidth: contentRow.implicitWidth
spacing: 8 spacing: 8
IconTextButton { IconButton {
id: detailsButton id: detailsButton
Layout.alignment: Qt.AlignLeft Layout.alignment: Qt.AlignLeft
horizontalPadding: Tokens.padding.medium
icon: "info" icon: "info"
inactiveColor: Colors.palette.m3surfaceContainer inactiveColor: Colors.palette.m3surfaceContainer
inactiveOnColor: Colors.palette.m3onSurface inactiveOnColor: Colors.palette.m3onSurface
isRound: true isRound: true
shapeMorph: true shapeMorph: true
text: "Details" verticalPadding: Tokens.padding.medium
onClicked: { onClicked: {
panelWindow.detailsOpen = !panelWindow.detailsOpen; panelWindow.detailsOpen = !panelWindow.detailsOpen;
+16 -7
View File
@@ -30,7 +30,7 @@ ColumnLayout {
function findAnchor(item: Item, anchor: string): Item { function findAnchor(item: Item, anchor: string): Item {
if (!item) if (!item)
return null; return null;
if (item.settingAnchor !== undefined && item.settingAnchor === anchor) if (item.settingAnchor !== undefined && item.settingAnchor === anchor) // qmllint disable missing-property
return item; return item;
const kids = item.children; const kids = item.children;
for (let i = 0; i < kids.length; i++) { for (let i = 0; i < kids.length; i++) {
@@ -43,8 +43,8 @@ ColumnLayout {
function highlightAnchor(anchor: string): void { function highlightAnchor(anchor: string): void {
const row = findAnchor(contentChild, anchor); const row = findAnchor(contentChild, anchor);
if (row && row.flashHighlight !== undefined) if (row && row.flashHighlight !== undefined) // qmllint disable missing-property
row.flashHighlight(); row.flashHighlight(); // qmllint disable missing-property
} }
function scrollToAnchor(anchor: string): bool { function scrollToAnchor(anchor: string): bool {
@@ -61,8 +61,8 @@ ColumnLayout {
root.animateScroll = true; root.animateScroll = true;
flickable.contentY = target; flickable.contentY = target;
Qt.callLater(() => root.animateScroll = false); Qt.callLater(() => root.animateScroll = false);
if (row.flashHighlight !== undefined) if (row.flashHighlight !== undefined) // qmllint disable missing-property
row.flashHighlight(); row.flashHighlight(); // qmllint disable missing-property
return true; return true;
} }
@@ -154,11 +154,20 @@ ColumnLayout {
Layout.fillHeight: true Layout.fillHeight: true
Layout.fillWidth: true Layout.fillWidth: true
Layout.topMargin: -topMargin Layout.topMargin: -topMargin
bottomMargin: Tokens.padding.extraLarge bottomMargin: Tokens.padding.extraLarge
topMargin: Tokens.padding.large
fadeAmount: 0.1
contentHeight: root.contentChild?.implicitHeight ?? 0 contentHeight: root.contentChild?.implicitHeight ?? 0
contentItem.children: [root.contentChild] contentItem.children: [root.contentChild]
fadeAmount: 0.1
topMargin: Tokens.padding.large rebound: Transition {
Anim {
properties: "x,y"
type: Anim.DefaultEffects
}
}
Behavior on contentY { Behavior on contentY {
enabled: root.animateScroll enabled: root.animateScroll
+12 -19
View File
@@ -81,14 +81,10 @@ VerticalFadeFlickable {
topLeftRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall 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 topRightRadius: stateLayer.pressed ? Tokens.rounding.medium : isCurrentPage ? Tokens.rounding.extraLarge : isCategoryStart ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
RadiusBehavior on bottomLeftRadius { RadiusBehavior on bottomLeftRadius {}
} RadiusBehavior on bottomRightRadius {}
RadiusBehavior on bottomRightRadius { RadiusBehavior on topLeftRadius {}
} RadiusBehavior on topRightRadius {}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
StateLayer { StateLayer {
id: stateLayer id: stateLayer
@@ -262,14 +258,10 @@ VerticalFadeFlickable {
topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall topRightRadius: layer.pressed ? Tokens.rounding.largeIncreased : isFirst ? Tokens.rounding.largeIncreased : Tokens.rounding.extraSmall
width: cardList.width width: cardList.width
RadiusBehavior on bottomLeftRadius { RadiusBehavior on bottomLeftRadius {}
} RadiusBehavior on bottomRightRadius {}
RadiusBehavior on bottomRightRadius { RadiusBehavior on topLeftRadius {}
} RadiusBehavior on topRightRadius {}
RadiusBehavior on topLeftRadius {
}
RadiusBehavior on topRightRadius {
}
ColumnLayout { ColumnLayout {
id: resultLayout id: resultLayout
@@ -318,9 +310,10 @@ VerticalFadeFlickable {
z: 1 z: 1
onClicked: { onClicked: {
root.sState.jumpToSetting(result.modelData.pageIdx, result.modelData.subPath, result.modelData.anchor); const target = result.modelData.targetSubPath;
} root.sState.jumpToSetting(result.modelData.pageIdx, target.length > 0 ? target : result.modelData.subPath, target.length > 0 ? "" : result.modelData.anchor);
}
} }
CustomSwitch { CustomSwitch {
@@ -67,6 +67,20 @@ PageBase {
SectionHeader { SectionHeader {
first: true 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") text: qsTr("Appearance")
} }
@@ -120,6 +120,7 @@ PageBase {
NavRow { NavRow {
text: qsTr("AI chat") text: qsTr("AI chat")
settingAnchor: "panels-sidebar-llm"
icon: "robot_2" icon: "robot_2"
first: true first: true
last: true last: true
+6
View File
@@ -38,6 +38,9 @@ PageBase {
spacing: Tokens.spacing.small spacing: Tokens.spacing.small
IconTextButton { IconTextButton {
property string settingAnchor: "style-wallpapers"
enabled: Config.background.enabled enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge horizontalPadding: Tokens.padding.extraLarge
icon: "wallpaper" icon: "wallpaper"
@@ -51,6 +54,9 @@ PageBase {
} }
IconTextButton { IconTextButton {
property string settingAnchor: "style-colors-fonts"
enabled: Config.background.enabled enabled: Config.background.enabled
horizontalPadding: Tokens.padding.extraLarge horizontalPadding: Tokens.padding.extraLarge
icon: "palette" icon: "palette"
+84 -59
View File
@@ -1,66 +1,24 @@
pragma Singleton pragma Singleton
import "../../scripts/fzf.js" as Fzf import "../../scripts/fzf.js" as Fzf
import "../../scripts/settings-indexer.js" as SettingsIndexer
import QtQuick import QtQuick
import Quickshell import Quickshell
import ZShell import ZShell
import ZShell.Config import ZShell.Config
import qs.Paths
Singleton { Singleton {
id: root id: root
property var fzfFinder: null property var inverted: ({})
property var ranking: ({})
readonly property var highlightCache: ({ readonly property var highlightCache: ({
"search": "", "search": "",
"pattern": null "pattern": null
}) })
property var inverted: ({}) property var fzfFinder: null
property var ranking: ({}) readonly property string cachePath: Paths.cache + "/settings-index.json"
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> { function query(search: string): list<QtObject> {
const tokens = root.tokenize(search); const tokens = root.tokenize(search);
@@ -70,7 +28,7 @@ Singleton {
const scores = ({}); const scores = ({});
const hitCounts = ({}); const hitCounts = ({});
for (const token of tokens) { for (const token of tokens) {
const matches = root.lookup(token); const matches = root.lookup(token); // { id: weight }
for (const id in matches) { for (const id in matches) {
scores[id] = (scores[id] ?? 0) + matches[id]; scores[id] = (scores[id] ?? 0) + matches[id];
hitCounts[id] = (hitCounts[id] ?? 0) + 1; hitCounts[id] = (hitCounts[id] ?? 0) + 1;
@@ -103,13 +61,76 @@ Singleton {
return out; 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 { function tokenize(text: string): var {
return text.toLowerCase().split(/[^a-z0-9]+/).filter(t => t.length > 0); 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: { Component.onCompleted: {
try { try {
const data = JSON.parse(ZUtils.settingsIndex()); const data = root.loadIndex();
entries.model = data.entries; entries.model = data.entries;
root.inverted = data.inverted ?? {}; root.inverted = data.inverted ?? {};
root.ranking = data.ranking ?? {}; root.ranking = data.ranking ?? {};
@@ -122,6 +143,7 @@ Singleton {
limit: 25 limit: 25
}); });
} catch (e) { } catch (e) {
console.warn("SettingsSearcher: failed to build settings index:", e);
entries.model = []; entries.model = [];
root.inverted = {}; root.inverted = {};
root.ranking = {}; root.ranking = {};
@@ -132,22 +154,25 @@ Singleton {
Variants { Variants {
id: entries id: entries
SettingEntry { SettingEntry {}
}
} }
component SettingEntry: QtObject { component SettingEntry: QtObject {
readonly property string anchor: modelData.anchor ?? "" required property var modelData
readonly property int pageIdx: modelData.pageIdx
readonly property var subPath: modelData.subPath
readonly property var targetSubPath: modelData.targetSubPath ?? []
readonly property var crumbIcons: modelData.crumbIcons readonly property var crumbIcons: modelData.crumbIcons
readonly property var crumbLabels: modelData.crumbLabels readonly property var crumbLabels: modelData.crumbLabels
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 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 string togglePath: modelData.togglePath ?? ""
readonly property bool isToggle: togglePath.length > 0
readonly property bool toggleValue: { readonly property bool toggleValue: {
if (!isToggle) if (!isToggle)
return false; return false;
+23
View File
@@ -1 +1,24 @@
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) add_subdirectory(ZShell)
+5 -55
View File
@@ -1,55 +1,3 @@
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 qml_module(ZShell
URI ZShell URI ZShell
SOURCES SOURCES
@@ -60,8 +8,6 @@ qml_module(ZShell
toaster.hpp toaster.cpp toaster.hpp toaster.cpp
qalculator.hpp qalculator.cpp qalculator.hpp qalculator.cpp
zutils.hpp zutils.cpp zutils.hpp zutils.cpp
RESOURCES
"${SETTINGS_INDEX_JSON}"
LIBRARIES LIBRARIES
Qt::Gui Qt::Gui
Qt::Quick Qt::Quick
@@ -69,9 +15,13 @@ qml_module(ZShell
Qt::Sql Qt::Sql
Qt::DBus Qt::DBus
PkgConfig::Qalculate PkgConfig::Qalculate
zshell-util
) )
target_compile_definitions(ZShell PRIVATE ZSHELL_VERSION="${VERSION}") target_compile_definitions(ZShell PRIVATE
ZSHELL_VERSION="${VERSION}"
GIT_REVISION="${GIT_REVISION}"
)
add_subdirectory(Models) add_subdirectory(Models)
add_subdirectory(Internal) add_subdirectory(Internal)
+1
View File
@@ -24,6 +24,7 @@ class Llm : public ConfigObject {
CFG_PROPERTY(QString, model, "") CFG_PROPERTY(QString, model, "")
CFG_PROPERTY(double, temperature, 0.7) CFG_PROPERTY(double, temperature, 0.7)
CFG_PROPERTY(bool, tools, true) CFG_PROPERTY(bool, tools, true)
CFG_PROPERTY(bool, enabled, true)
CONFIG_SUBOBJECT(LlmAppearance, appearance) CONFIG_SUBOBJECT(LlmAppearance, appearance)
public: public:
+1
View File
@@ -288,6 +288,7 @@ qml_module(ZShell-llm
chat.hpp chat.cpp chat.hpp chat.cpp
chatstore.hpp chatstore.cpp chatstore.hpp chatstore.cpp
codehighlighter.hpp codehighlighter.cpp codehighlighter.hpp codehighlighter.cpp
filetool.hpp filetool.cpp
generation.hpp generation.cpp generation.hpp generation.cpp
llmclient.hpp llmclient.cpp llmclient.hpp llmclient.cpp
markdownblock.hpp markdownblock.hpp
+7 -1
View File
@@ -1,6 +1,7 @@
#include "chat.hpp" #include "chat.hpp"
#include "config.hpp" #include "config.hpp"
#include "filetool.hpp"
#include "llm.hpp" #include "llm.hpp"
#include "llmclient.hpp" #include "llmclient.hpp"
#include "webfetchtool.hpp" #include "webfetchtool.hpp"
@@ -17,6 +18,7 @@ Chat::Chat(QObject* parent)
m_store->setLlmClient(m_client); m_store->setLlmClient(m_client);
m_client->tools()->registerTool(new WebFetchTool(m_client->tools())); 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(); const auto* llm = config::Config::instance()->llm();
m_client->setEndpoint(llm->endpoint()); m_client->setEndpoint(llm->endpoint());
@@ -38,7 +40,6 @@ Chat::Chat(QObject* parent)
}); });
connect(m_client, &LlmClient::busyChanged, this, [this]() { connect(m_client, &LlmClient::busyChanged, this, [this]() {
// A fresh run supersedes the previous error.
if (m_client->busy() && !m_lastError.isEmpty()) { if (m_client->busy() && !m_lastError.isEmpty()) {
m_lastError.clear(); m_lastError.clear();
Q_EMIT lastErrorChanged(); Q_EMIT lastErrorChanged();
@@ -137,6 +138,7 @@ QString Chat::streamingChatId() const {
return m_client->streamingChatId(); return m_client->streamingChatId();
} }
Chat* Chat::s_instance = nullptr; Chat* Chat::s_instance = nullptr;
Chat* Chat::create(QQmlEngine*, QJSEngine*) { Chat* Chat::create(QQmlEngine*, QJSEngine*) {
@@ -158,6 +160,10 @@ void Chat::refreshModels() {
m_client->refreshModels(); m_client->refreshModels();
} }
void Chat::refreshFromServer() {
m_client->refreshFromServer();
}
void Chat::selectModel(const QString& id) { void Chat::selectModel(const QString& id) {
if (id.isEmpty()) return; if (id.isEmpty()) return;
m_client->setModel(id); m_client->setModel(id);
+1 -3
View File
@@ -14,9 +14,6 @@ namespace ZShell::llm {
class LlmClient; 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 { class Chat : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -55,6 +52,7 @@ class Chat : public QObject {
Q_INVOKABLE void stop(); Q_INVOKABLE void stop();
Q_INVOKABLE void dismissError(); Q_INVOKABLE void dismissError();
Q_INVOKABLE void refreshModels(); Q_INVOKABLE void refreshModels();
Q_INVOKABLE void refreshFromServer();
Q_INVOKABLE void selectModel(const QString& id); Q_INVOKABLE void selectModel(const QString& id);
static Chat* create(QQmlEngine*, QJSEngine*); static Chat* create(QQmlEngine*, QJSEngine*);
+5 -6
View File
@@ -37,10 +37,10 @@ class CodeHighlighter : public QObject {
private: private:
struct State { struct State {
bool bad = false; // permanent failure, do not retry bool bad = false;
void* lib = nullptr; void* lib = nullptr;
const void* lang = nullptr; // const TSLanguage* const void* lang = nullptr;
void* query = nullptr; // TSQuery* void* query = nullptr;
}; };
[[nodiscard]] static const QHash<QString, QString>& aliases(); [[nodiscard]] static const QHash<QString, QString>& aliases();
@@ -59,15 +59,14 @@ class CodeHighlighter : public QObject {
const QVariantList& spans) const; const QVariantList& spans) const;
struct SpanCacheEntry { struct SpanCacheEntry {
QString code; // re-compared on lookup; a hash collision can QString code;
// never deliver the wrong spans
QVariantList spans; QVariantList spans;
}; };
mutable QHash<QString, std::shared_ptr<const State>> m_states; mutable QHash<QString, std::shared_ptr<const State>> m_states;
mutable QMutex m_stateMutex; mutable QMutex m_stateMutex;
mutable QHash<QString, SpanCacheEntry> m_spanCache; mutable QHash<QString, SpanCacheEntry> m_spanCache;
mutable QStringList m_spanCacheOrder; // LRU order, oldest first mutable QStringList m_spanCacheOrder;
mutable int m_spanCacheBytes = 0; mutable int m_spanCacheBytes = 0;
mutable QMutex m_cacheMutex; mutable QMutex m_cacheMutex;
static CodeHighlighter* s_instance; static CodeHighlighter* s_instance;
+153
View File
@@ -0,0 +1,153 @@
#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
@@ -0,0 +1,23 @@
#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
+45 -2
View File
@@ -1,5 +1,9 @@
#include "generation.hpp" #include "generation.hpp"
#include "llmclient.hpp"
#include "messagemodel.hpp"
#include "session.hpp"
#include <QDateTime> #include <QDateTime>
namespace ZShell::llm { namespace ZShell::llm {
@@ -46,7 +50,7 @@ QString ChatGeneration::reasoning() const {
bool ChatGeneration::reasoningActive() const { bool ChatGeneration::reasoningActive() const {
if (!m_streaming) return false; if (!m_streaming) return false;
if (!content().isEmpty()) return false; if (!content().isEmpty()) return false;
return !hasRunningTool(); return !hasRunningTool() && !hasPendingTool();
} }
qint64 ChatGeneration::reasoningElapsedMs() const { qint64 ChatGeneration::reasoningElapsedMs() const {
@@ -87,6 +91,45 @@ bool ChatGeneration::hasRunningTool() const {
return false; 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() { void ChatGeneration::updateReasoningActive() {
const bool active = reasoningActive(); const bool active = reasoningActive();
if (m_reasoningActive == active) return; if (m_reasoningActive == active) return;
@@ -177,7 +220,7 @@ LlmSegment* ChatGeneration::beginToolCall(
LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this); LlmSegment::Type::ToolCall, QDateTime::currentMSecsSinceEpoch(), this);
segment->setName(name); segment->setName(name);
segment->setToolCallId(toolCallId); segment->setToolCallId(toolCallId);
segment->setStatus(LlmSegment::Status::Running); segment->setStatus(LlmSegment::Status::Pending);
segment->begin(); segment->begin();
addSegment(segment); addSegment(segment);
Q_EMIT toolStateChanged(); Q_EMIT toolStateChanged();
+20
View File
@@ -6,10 +6,13 @@
#include <QObject> #include <QObject>
#include <QString> #include <QString>
#include <QTimer> #include <QTimer>
#include <QVariantList>
#include <QtQml> #include <QtQml>
namespace ZShell::llm { namespace ZShell::llm {
class LlmClient;
class ChatGeneration : public QObject { class ChatGeneration : public QObject {
Q_OBJECT Q_OBJECT
QML_ELEMENT QML_ELEMENT
@@ -33,6 +36,12 @@ class ChatGeneration : public QObject {
QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY QList<ZShell::llm::LlmSegment*> segments READ segments NOTIFY
segmentsChanged) segmentsChanged)
Q_PROPERTY(int toolCallCount READ toolCallCount 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: public:
explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr); explicit ChatGeneration(qint64 timestamp, QObject* parent = nullptr);
@@ -48,6 +57,14 @@ class ChatGeneration : public QObject {
[[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; } [[nodiscard]] QList<LlmSegment*> segments() const { return m_segments; }
[[nodiscard]] int toolCallCount() const; [[nodiscard]] int toolCallCount() const;
[[nodiscard]] bool hasRunningTool() 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 setContent(const QString& value);
void appendContent(const QString& piece); void appendContent(const QString& piece);
@@ -69,14 +86,17 @@ class ChatGeneration : public QObject {
void streamingChanged(); void streamingChanged();
void toolStateChanged(); void toolStateChanged();
void segmentsChanged(); void segmentsChanged();
void toolApprovalPendingChanged();
private: private:
void updateReasoningActive(); void updateReasoningActive();
LlmClient* client() const;
QTimer m_timer; QTimer m_timer;
QList<LlmSegment*> m_segments; QList<LlmSegment*> m_segments;
bool m_reasoningActive = false; bool m_reasoningActive = false;
bool m_streaming = false; bool m_streaming = false;
bool m_approvalPending = false;
qint64 m_timestamp; qint64 m_timestamp;
}; };
+387 -84
View File
@@ -12,11 +12,117 @@
#include <QJsonObject> #include <QJsonObject>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
#include <QRegularExpression>
#include <QSet> #include <QSet>
#include <QUrl> #include <QUrl>
#include <memory>
namespace ZShell::llm { 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( QString LlmClient::completionsPath(
const QString& endpoint, const QString& subpath) { const QString& endpoint, const QString& subpath) {
QString base = endpoint.trimmed(); QString base = endpoint.trimmed();
@@ -65,7 +171,12 @@ LlmClient::LlmClient(QObject* parent) : QObject(parent) {
&ToolRegistry::enabledChanged, &ToolRegistry::enabledChanged,
this, this,
&LlmClient::toolsEnabledChanged); &LlmClient::toolsEnabledChanged);
probeContextSize();
m_refreshTimer.setParent(this);
m_refreshTimer.setInterval(kRefreshIntervalMs);
connect(
&m_refreshTimer, &QTimer::timeout, this, &LlmClient::refreshFromServer);
m_refreshTimer.start();
} }
LlmClient::~LlmClient() { LlmClient::~LlmClient() {
@@ -78,8 +189,8 @@ void LlmClient::setEndpoint(const QString& value) {
if (m_endpoint == value) return; if (m_endpoint == value) return;
m_endpoint = value; m_endpoint = value;
Q_EMIT endpointChanged(); Q_EMIT endpointChanged();
probeContextSize(); m_refreshTimer.start();
if (m_model.isEmpty()) refreshModels(); refreshFromServer();
} }
void LlmClient::setModel(const QString& value) { void LlmClient::setModel(const QString& value) {
@@ -95,9 +206,11 @@ void LlmClient::setTemperature(double value) {
void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) { void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
if (m_busy || !session || !target) return; if (m_busy || !session || !target) return;
refreshModels();
m_contextRetryUsed = false;
m_active = session; m_active = session;
m_streaming = target; m_streaming = target;
m_streaming->setStreaming(true); target->setStreaming(true);
setBusy(true); setBusy(true);
setStreamingChatId(session->id()); setStreamingChatId(session->id());
@@ -125,12 +238,17 @@ void LlmClient::startGeneration(ChatSession* session, ChatGeneration* target) {
m_round = 0; m_round = 0;
m_contentMark = 0; m_contentMark = 0;
m_reasoningMark = 0; m_reasoningMark = 0;
setApprovalPending(false);
sendRound(); probeContextSize([this, session, target]() {
if (m_active != session || m_streaming != target) return;
sendRound();
});
} }
void LlmClient::sendRound() { void LlmClient::sendRound() {
if (!m_active || !m_streaming) return; if (!m_active || !m_streaming) return;
probeContextSize();
m_finishReason.clear(); m_finishReason.clear();
m_callBuilders.clear(); m_callBuilders.clear();
m_callResults.clear(); m_callResults.clear();
@@ -146,9 +264,11 @@ void LlmClient::sendRound() {
return; return;
} }
const auto* model = m_active->messagesModel(); ChatSession* active = m_active;
ChatGeneration* streaming = m_streaming;
const auto* model = active->messagesModel();
const int targetRow = const int targetRow =
model->rowOf(qobject_cast<ChatMessage*>(m_streaming->parent())); model->rowOf(qobject_cast<ChatMessage*>(streaming->parent()));
if (targetRow < 0) { if (targetRow < 0) {
fail(QStringLiteral( fail(QStringLiteral(
"Internal error: generation target is not in the " "Internal error: generation target is not in the "
@@ -156,10 +276,38 @@ void LlmClient::sendRound() {
return; return;
} }
QJsonArray messages = buildContextMessages(m_active, targetRow); int transcriptTokens = 0;
for (const QJsonValue& value : m_transcript)
transcriptTokens += estimateMessageTokens(value.toObject());
QJsonArray messages =
buildContextMessages(m_active, targetRow, transcriptTokens);
for (const QJsonValue& value : m_transcript) for (const QJsonValue& value : m_transcript)
messages.append(value); 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 body;
QJsonObject streamOptions; QJsonObject streamOptions;
streamOptions[QStringLiteral("include_usage")] = true; streamOptions[QStringLiteral("include_usage")] = true;
@@ -199,73 +347,117 @@ void LlmClient::sendRound() {
roundFinished(); roundFinished();
else if (error == QNetworkReply::OperationCanceledError) else if (error == QNetworkReply::OperationCanceledError)
finishTurn(); finishTurn();
else else {
fail(serverErrorMessage(responseBody, errorString)); 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);
}
}); });
} }
QJsonArray LlmClient::buildContextMessages( QJsonArray LlmClient::buildContextMessages(
ChatSession* session, int stopBeforeRow) const { ChatSession* session, int stopBeforeRow, int extraReserveTokens) const {
const auto* model = session->messagesModel(); const auto* model = session->messagesModel();
QJsonArray messages; QList<ContextUnit> units;
for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) { for (int row = model->rowCount() - 1; row > stopBeforeRow; --row) {
const auto* message = model->at(row); const auto* message = model->at(row);
const auto* generation = message->activeGeneration(); const auto* generation = message->activeGeneration();
if (!generation) continue; if (!generation) continue;
ContextUnit unit;
if (message->role() == ChatMessage::Role::User) { if (message->role() == ChatMessage::Role::User) {
QJsonObject user; QJsonObject user;
user[QStringLiteral("role")] = QStringLiteral("user"); user[QStringLiteral("role")] = QStringLiteral("user");
user[QStringLiteral("content")] = generation->content(); user[QStringLiteral("content")] = generation->content();
messages.append(user); unit.messages.append(user);
continue; } else {
} QList<const LlmSegment*> toolSegments;
for (const auto* segment : generation->segments())
if (segment->type() == LlmSegment::Type::ToolCall)
toolSegments.append(segment);
QList<const LlmSegment*> toolSegments; QJsonObject assistant;
for (const auto* segment : generation->segments()) assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (segment->type() == LlmSegment::Type::ToolCall) if (toolSegments.isEmpty())
toolSegments.append(segment); assistant[QStringLiteral("content")] = generation->content();
else if (!generation->content().isEmpty())
QJsonObject assistant; assistant[QStringLiteral("content")] = generation->content();
assistant[QStringLiteral("role")] = QStringLiteral("assistant"); if (!generation->reasoning().isEmpty())
if (toolSegments.isEmpty()) assistant[QStringLiteral("reasoning_content")] =
assistant[QStringLiteral("content")] = generation->content(); generation->reasoning();
else if (!generation->content().isEmpty()) if (!toolSegments.isEmpty()) {
assistant[QStringLiteral("content")] = generation->content(); QJsonArray calls;
if (!generation->reasoning().isEmpty()) for (const auto* segment : toolSegments) {
assistant[QStringLiteral("reasoning_content")] = QJsonObject function;
generation->reasoning(); function[QStringLiteral("name")] = segment->name();
function[QStringLiteral("arguments")] =
if (toolSegments.isEmpty()) { segment->arguments();
messages.append(assistant); QJsonObject call;
continue; call[QStringLiteral("id")] = segment->toolCallId();
} call[QStringLiteral("type")] = QStringLiteral("function");
QJsonArray calls; call[QStringLiteral("function")] = function;
for (const auto* segment : toolSegments) { calls.append(call);
QJsonObject function; }
function[QStringLiteral("name")] = segment->name(); assistant[QStringLiteral("tool_calls")] = calls;
function[QStringLiteral("arguments")] = segment->arguments(); }
QJsonObject call; unit.messages.append(assistant);
call[QStringLiteral("id")] = segment->toolCallId(); for (const auto* segment : toolSegments) {
call[QStringLiteral("type")] = QStringLiteral("function"); QJsonObject toolMessage;
call[QStringLiteral("function")] = function; toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
calls.append(call); toolMessage[QStringLiteral("tool_call_id")] =
} segment->toolCallId();
assistant[QStringLiteral("tool_calls")] = calls; toolMessage[QStringLiteral("content")] = segment->result();
messages.append(assistant); unit.messages.append(toolMessage);
for (const auto* segment : toolSegments) { }
QJsonObject toolMessage;
toolMessage[QStringLiteral("role")] = QStringLiteral("tool");
toolMessage[QStringLiteral("tool_call_id")] = segment->toolCallId();
toolMessage[QStringLiteral("content")] = segment->result();
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; return messages;
} }
void LlmClient::applyToolCallDelta(const QJsonObject& call) { void LlmClient::applyToolCallDelta(const QJsonObject& call) {
if (!m_streaming) return; ChatGeneration* streaming = m_streaming;
if (!streaming) return;
const int index = call[QStringLiteral("index")].toInt(-1); const int index = call[QStringLiteral("index")].toInt(-1);
if (index < 0) return; if (index < 0) return;
while (m_callBuilders.size() <= index) while (m_callBuilders.size() <= index)
@@ -281,8 +473,8 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) {
if (!arguments.isEmpty()) builder.arguments += arguments; if (!arguments.isEmpty()) builder.arguments += arguments;
if (!builder.segment) { if (!builder.segment) {
m_streaming->closeOpenSegments(); streaming->closeOpenSegments();
builder.segment = m_streaming->beginToolCall(builder.name, builder.id); builder.segment = streaming->beginToolCall(builder.name, builder.id);
} }
builder.segment->setName(builder.name); builder.segment->setName(builder.name);
builder.segment->setToolCallId(builder.id); builder.segment->setToolCallId(builder.id);
@@ -291,12 +483,27 @@ void LlmClient::applyToolCallDelta(const QJsonObject& call) {
void LlmClient::roundFinished() { void LlmClient::roundFinished() {
if (m_roundDone || !m_streaming) return; if (m_roundDone || !m_streaming) return;
ChatGeneration* streaming = m_streaming;
m_roundDone = true; m_roundDone = true;
bool hasCalls = false; bool hasCalls = false;
for (const auto& builder : m_callBuilders) for (const auto& builder : m_callBuilders)
if (builder.seen) hasCalls = true; if (builder.seen) hasCalls = true;
if (m_finishReason != QLatin1String("tool_calls") && !hasCalls) { 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);
}
finishTurn(); finishTurn();
return; return;
} }
@@ -306,10 +513,10 @@ void LlmClient::roundFinished() {
return; return;
} }
m_streaming->closeOpenSegments(); streaming->closeOpenSegments();
const QString content = m_streaming->content(); const QString content = streaming->content();
const QString reasoning = m_streaming->reasoning(); const QString reasoning = streaming->reasoning();
QJsonObject assistant; QJsonObject assistant;
assistant[QStringLiteral("role")] = QStringLiteral("assistant"); assistant[QStringLiteral("role")] = QStringLiteral("assistant");
if (content.size() > m_contentMark) { if (content.size() > m_contentMark) {
@@ -337,7 +544,7 @@ void LlmClient::roundFinished() {
m_transcript.append(assistant); m_transcript.append(assistant);
m_round++; m_round++;
executeAllCalls(); setApprovalPending(true);
} }
void LlmClient::executeAllCalls() { void LlmClient::executeAllCalls() {
@@ -345,6 +552,18 @@ void LlmClient::executeAllCalls() {
m_pendingCalls = 0; m_pendingCalls = 0;
m_callResults = QList<ToolCallResult>(m_callBuilders.size()); 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) { for (int i = 0; i < m_callBuilders.size(); ++i) {
const auto& call = m_callBuilders.at(i); const auto& call = m_callBuilders.at(i);
if (!call.seen) continue; if (!call.seen) continue;
@@ -375,18 +594,22 @@ void LlmClient::executeAllCalls() {
} }
++m_pendingCalls; ++m_pendingCalls;
tool->execute(args, [this, i, call](const QJsonObject& result) { tool->execute(
if (!m_streaming) return; call.id,
const bool success = result.contains(QStringLiteral("output")); args,
const QString content = perCallChars,
success ? result[QStringLiteral("output")].toString() [this, i, call](const QJsonObject& result) {
: QStringLiteral("Error: ") + if (!m_streaming) return;
result[QStringLiteral("error")].toString(); const bool success = result.contains(QStringLiteral("output"));
m_callResults[i] = {content, success}; const QString content =
if (LlmSegment* segment = call.segment) success ? result[QStringLiteral("output")].toString()
segment->finishTool(content, success); : QStringLiteral("Error: ") +
if (--m_pendingCalls == 0) flushCallResults(); result[QStringLiteral("error")].toString();
}); m_callResults[i] = {content, success};
if (LlmSegment* segment = call.segment)
segment->finishTool(content, success);
if (--m_pendingCalls == 0) flushCallResults();
});
} }
if (m_pendingCalls == 0) flushCallResults(); if (m_pendingCalls == 0) flushCallResults();
} }
@@ -407,12 +630,56 @@ void LlmClient::flushCallResults() {
sendRound(); 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() { void LlmClient::stop() {
if (!m_busy) return; if (!m_busy) return;
if (m_approvalPending) {
setApprovalPending(false);
finishPendingCalls(QStringLiteral("Cancelled"));
finishTurn();
return;
}
if (m_toolPhase) { if (m_toolPhase) {
m_tools->cancelAll(); m_tools->cancelAll();
if (m_streaming) { if (ChatGeneration* streaming = m_streaming) {
for (auto* segment : m_streaming->segments()) { for (auto* segment : streaming->segments()) {
if (segment->type() == LlmSegment::Type::ToolCall && if (segment->type() == LlmSegment::Type::ToolCall &&
segment->running()) segment->running())
segment->finishTool(QStringLiteral("Cancelled"), false); segment->finishTool(QStringLiteral("Cancelled"), false);
@@ -421,16 +688,25 @@ void LlmClient::stop() {
finishTurn(); finishTurn();
return; return;
} }
if (m_reply) m_reply->abort(); if (m_reply)
m_reply->abort();
else
endStream();
} }
void LlmClient::endStream() { void LlmClient::endStream() {
if (!m_streaming) return; if (!m_streaming) return;
auto* generation = m_streaming; ChatGeneration* generation = m_streaming;
auto* session = m_active; ChatSession* session = m_active;
m_streaming = nullptr; m_streaming = nullptr;
m_active = nullptr; m_active = nullptr;
generation->setStreaming(false); 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() && if (generation->content().isEmpty() && generation->reasoning().isEmpty() &&
generation->toolCallCount() == 0) { generation->toolCallCount() == 0) {
if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) { if (auto* message = qobject_cast<ChatMessage*>(generation->parent())) {
@@ -454,6 +730,7 @@ void LlmClient::finishTurn() {
m_pendingClear.clear(); m_pendingClear.clear();
} }
if (session) session->persist(); if (session) session->persist();
refreshFromServer();
} }
void LlmClient::clearOnFinish(ChatSession* session) { void LlmClient::clearOnFinish(ChatSession* session) {
@@ -528,6 +805,12 @@ void LlmClient::handleLine(const QByteArray& line) {
} }
} }
void LlmClient::refreshFromServer() {
if (m_endpoint.trimmed().isEmpty()) return;
probeContextSize();
refreshModels();
}
void LlmClient::refreshModels() { void LlmClient::refreshModels() {
const QUrl url = const QUrl url =
QUrl::fromUserInput(completionsPath(m_endpoint, "/models")); QUrl::fromUserInput(completionsPath(m_endpoint, "/models"));
@@ -563,6 +846,9 @@ void LlmClient::refreshModels() {
if (m_model.isEmpty()) { if (m_model.isEmpty()) {
m_model = models.first(); m_model = models.first();
Q_EMIT modelChanged(); Q_EMIT modelChanged();
} else if (!models.contains(m_model)) {
m_model = models.first();
Q_EMIT modelChanged();
} }
}); });
} }
@@ -570,18 +856,26 @@ void LlmClient::refreshModels() {
void LlmClient::setContextSize(int size) { void LlmClient::setContextSize(int size) {
if (size <= 0 || m_contextSize == size) return; if (size <= 0 || m_contextSize == size) return;
m_contextSize = size; m_contextSize = size;
m_tools->setContextSize(size);
Q_EMIT contextSizeChanged(); Q_EMIT contextSizeChanged();
} }
void LlmClient::probeContextSize() { void LlmClient::probeContextSize(std::function<void()> done) {
QString base = m_endpoint.trimmed(); QString base = m_endpoint.trimmed();
while (base.endsWith('/')) while (base.endsWith('/'))
base.chop(1); base.chop(1);
const QUrl url = QUrl::fromUserInput(base + "/props"); const QUrl url = QUrl::fromUserInput(base + "/props");
if (!url.isValid() || url.host().isEmpty()) return; if (!url.isValid() || url.host().isEmpty()) {
if (done) done();
return;
}
auto* reply = m_manager.get(QNetworkRequest(url)); auto* reply = m_manager.get(QNetworkRequest(url));
connect(reply, &QNetworkReply::finished, this, [this, reply]() { auto settled = std::make_shared<bool>(false);
const auto finish = [this, reply, settled, done]() {
if (*settled) return;
*settled = true;
const QNetworkReply::NetworkError error = reply->error(); const QNetworkReply::NetworkError error = reply->error();
const QByteArray data = reply->readAll(); const QByteArray data = reply->readAll();
reply->deleteLater(); reply->deleteLater();
@@ -606,17 +900,26 @@ void LlmClient::probeContextSize() {
.toInt(0); .toInt(0);
} }
} }
setContextSize(size > 0 ? size : 4096); 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();
}); });
} }
void LlmClient::updateTokenUsage(const QJsonObject& data) { void LlmClient::updateTokenUsage(const QJsonObject& data) {
if (!m_active || m_contextSize <= 0) return; ChatSession* active = m_active;
if (!active || m_contextSize <= 0) return;
const QJsonObject usage = data["usage"].toObject(); const QJsonObject usage = data["usage"].toObject();
if (usage.isEmpty()) return; if (usage.isEmpty()) return;
const double used = usage.value("prompt_tokens").toDouble() + const double used = usage.value("prompt_tokens").toDouble() +
usage.value("completion_tokens").toDouble(); usage.value("completion_tokens").toDouble();
if (used > 0) m_active->setLastTokenCount(static_cast<int>(used)); if (used > 0) active->setLastTokenCount(static_cast<int>(used));
} }
void LlmClient::shortRequest( void LlmClient::shortRequest(
+14 -4
View File
@@ -10,6 +10,7 @@
#include <QPointer> #include <QPointer>
#include <QString> #include <QString>
#include <QStringList> #include <QStringList>
#include <QTimer>
#include <functional> #include <functional>
@@ -42,7 +43,8 @@ class LlmClient : public QObject {
void setModel(const QString& value); void setModel(const QString& value);
void setTemperature(double value); void setTemperature(double value);
void setContextSize(int size); void setContextSize(int size);
void probeContextSize(); void probeContextSize(std::function<void()> done = {});
Q_INVOKABLE void refreshFromServer();
[[nodiscard]] bool busy() const { return m_busy; } [[nodiscard]] bool busy() const { return m_busy; }
[[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); } [[nodiscard]] bool toolsEnabled() const { return m_tools->enabled(); }
@@ -56,6 +58,9 @@ class LlmClient : public QObject {
void clearOnFinish(ChatSession* session); void clearOnFinish(ChatSession* session);
void sessionRemoved(ChatSession* session); void sessionRemoved(ChatSession* session);
void approveTools();
void denyTools();
void refreshModels(); void refreshModels();
void requestTitle(ChatSession* session, const QString& userText); void requestTitle(ChatSession* session, const QString& userText);
void requestIcon(ChatSession* session, const QString& userText); void requestIcon(ChatSession* session, const QString& userText);
@@ -83,11 +88,13 @@ class LlmClient : public QObject {
void sendRound(); void sendRound();
QJsonArray buildContextMessages( QJsonArray buildContextMessages(
ChatSession* session, int stopBeforeRow) const; ChatSession* session, int stopBeforeRow, int extraReserveTokens) const;
void applyToolCallDelta(const QJsonObject& call); void applyToolCallDelta(const QJsonObject& call);
void roundFinished(); void roundFinished();
void executeAllCalls(); void executeAllCalls();
void flushCallResults(); void flushCallResults();
void setApprovalPending(bool value);
void finishPendingCalls(const QString& resultText);
void finishTurn(); void finishTurn();
void fail(const QString& message); void fail(const QString& message);
void handleLine(const QByteArray& line); void handleLine(const QByteArray& line);
@@ -109,8 +116,9 @@ class LlmClient : public QObject {
ToolRegistry* m_tools = nullptr; ToolRegistry* m_tools = nullptr;
QNetworkReply* m_reply = nullptr; QNetworkReply* m_reply = nullptr;
QByteArray m_buffer; QByteArray m_buffer;
ChatSession* m_active = nullptr; QTimer m_refreshTimer;
ChatGeneration* m_streaming = nullptr; QPointer<ChatSession> m_active;
QPointer<ChatGeneration> m_streaming;
QPointer<ChatSession> m_pendingClear; QPointer<ChatSession> m_pendingClear;
bool m_busy = false; bool m_busy = false;
QString m_streamingChatId; QString m_streamingChatId;
@@ -133,6 +141,8 @@ class LlmClient : public QObject {
qsizetype m_reasoningMark = 0; qsizetype m_reasoningMark = 0;
bool m_roundDone = false; bool m_roundDone = false;
bool m_toolPhase = false; bool m_toolPhase = false;
bool m_approvalPending = false;
bool m_contextRetryUsed = false;
int m_pendingCalls = 0; int m_pendingCalls = 0;
static constexpr int kMaxToolRounds = 12; 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 }; enum class Type : int { Reasoning = 0, ToolCall, Content };
Q_ENUM(Type) Q_ENUM(Type)
enum class Status : int { None = 0, Running, Success, Error }; enum class Status : int { None = 0, Running, Success, Error, Pending };
Q_ENUM(Status) Q_ENUM(Status)
explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr); explicit LlmSegment(Type type, qint64 timestamp, QObject* parent = nullptr);
+20
View File
@@ -6,6 +6,18 @@ LlmTool::LlmTool(QObject* parent) : QObject(parent) {}
LlmTool::~LlmTool() = default; 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() {} void LlmTool::cancel() {}
QJsonObject LlmTool::specification() const { QJsonObject LlmTool::specification() const {
@@ -30,6 +42,7 @@ void ToolRegistry::setEnabled(bool value) {
void ToolRegistry::registerTool(LlmTool* tool) { void ToolRegistry::registerTool(LlmTool* tool) {
if (!tool || m_tools.contains(tool)) return; if (!tool || m_tools.contains(tool)) return;
tool->setParent(this); tool->setParent(this);
tool->setContextSize(m_contextSize);
m_tools.append(tool); m_tools.append(tool);
} }
@@ -52,4 +65,11 @@ void ToolRegistry::cancelAll() {
tool->cancel(); 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 } // namespace ZShell::llm
+16
View File
@@ -22,11 +22,24 @@ class LlmTool : public QObject {
[[nodiscard]] virtual QJsonObject parameters() const = 0; [[nodiscard]] virtual QJsonObject parameters() const = 0;
virtual void execute( virtual void execute(
const QString& toolCallId,
const QJsonObject& args, const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) = 0; std::function<void(const QJsonObject& result)> done) = 0;
virtual void cancel(); virtual void cancel();
[[nodiscard]] QJsonObject specification() const; [[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 { class ToolRegistry : public QObject {
@@ -45,11 +58,14 @@ class ToolRegistry : public QObject {
[[nodiscard]] QJsonArray specifications() const; [[nodiscard]] QJsonArray specifications() const;
void cancelAll(); void cancelAll();
void setContextSize(int value);
Q_SIGNALS: Q_SIGNALS:
void enabledChanged(); void enabledChanged();
private: private:
bool m_enabled = true; bool m_enabled = true;
int m_contextSize = 0;
QList<LlmTool*> m_tools; QList<LlmTool*> m_tools;
}; };
+69 -6
View File
@@ -1,5 +1,7 @@
#include "webfetchtool.hpp" #include "webfetchtool.hpp"
#include <QDir>
#include <QFile>
#include <QJsonArray> #include <QJsonArray>
#include <QNetworkReply> #include <QNetworkReply>
#include <QNetworkRequest> #include <QNetworkRequest>
@@ -8,6 +10,9 @@
namespace ZShell::llm { namespace ZShell::llm {
const QString WebFetchTool::StoragePath =
QStringLiteral("/tmp/zshell-llm/webfetch");
namespace { namespace {
const char* kUserAgent = const char* kUserAgent =
@@ -46,7 +51,10 @@ QString WebFetchTool::description() const {
return QStringLiteral( return QStringLiteral(
"Fetch content from an HTTP or HTTPS URL and return it as plain " "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 " "text or raw HTML. HTML pages are reduced to their visible text "
"by default. This tool is read-only."); "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.");
} }
QJsonObject WebFetchTool::parameters() const { QJsonObject WebFetchTool::parameters() const {
@@ -102,9 +110,14 @@ void WebFetchTool::completeJob(Job* job, QJsonObject result) {
} }
void WebFetchTool::execute( void WebFetchTool::execute(
const QJsonObject& args, std::function<void(const QJsonObject&)> done) { const QString& toolCallId,
const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject&)> done) {
auto* job = new Job; auto* job = new Job;
job->done = std::move(done); job->done = std::move(done);
job->toolCallId = toolCallId;
job->budgetChars = outputBudgetChars;
m_jobs.append(job); m_jobs.append(job);
auto fail = [this, job](const QString& message) { auto fail = [this, job](const QString& message) {
@@ -233,13 +246,63 @@ void WebFetchTool::execute(
if (mime.contains(QLatin1String("text/html")) && if (mime.contains(QLatin1String("text/html")) &&
job->format == QLatin1String("text")) job->format == QLatin1String("text"))
content = extractTextFromHtml(content); content = extractTextFromHtml(content);
if (content.size() > MaxOutputChars)
content = content.left(MaxOutputChars) + const QString savedPath = saveToFile(*job, content, mime);
QStringLiteral("\n[... truncated ...]");
completeJob(job, makeOutput(content)); 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));
}); });
} }
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() { void WebFetchTool::cancel() {
for (auto* job : m_jobs) { for (auto* job : m_jobs) {
job->timer->stop(); job->timer->stop();
+7 -1
View File
@@ -20,7 +20,7 @@ class WebFetchTool : public LlmTool {
static constexpr int MaxResponseBytes = 5 * 1024 * 1024; static constexpr int MaxResponseBytes = 5 * 1024 * 1024;
static constexpr int DefaultTimeoutSeconds = 30; static constexpr int DefaultTimeoutSeconds = 30;
static constexpr int MaxTimeoutSeconds = 120; static constexpr int MaxTimeoutSeconds = 120;
static constexpr int MaxOutputChars = 64 * 1024; static const QString StoragePath;
explicit WebFetchTool(QObject* parent = nullptr); explicit WebFetchTool(QObject* parent = nullptr);
~WebFetchTool() override; ~WebFetchTool() override;
@@ -29,7 +29,9 @@ class WebFetchTool : public LlmTool {
QString description() const override; QString description() const override;
QJsonObject parameters() const override; QJsonObject parameters() const override;
void execute( void execute(
const QString& toolCallId,
const QJsonObject& args, const QJsonObject& args,
int outputBudgetChars,
std::function<void(const QJsonObject& result)> done) override; std::function<void(const QJsonObject& result)> done) override;
void cancel() override; void cancel() override;
@@ -43,10 +45,14 @@ class WebFetchTool : public LlmTool {
QByteArray body; QByteArray body;
bool tooLarge = false; bool tooLarge = false;
QString format; QString format;
QString toolCallId;
int budgetChars = 0;
std::function<void(const QJsonObject& result)> done; std::function<void(const QJsonObject& result)> done;
}; };
void completeJob(Job* job, QJsonObject result); void completeJob(Job* job, QJsonObject result);
QString saveToFile(
const Job& job, const QString& content, const QString& mime) const;
QNetworkAccessManager m_manager; QNetworkAccessManager m_manager;
QList<Job*> m_jobs; QList<Job*> m_jobs;
+5 -2
View File
@@ -163,6 +163,7 @@ void Gpu::tick() {
const Type t = type(); const Type t = type();
if (t == Generic) { if (t == Generic) {
readGenericUsage(); readGenericUsage();
readGenericMemory();
readGpuTemperature(); readGpuTemperature();
} else if (t == Nvidia) { } else if (t == Nvidia) {
startNvidiaUsage(); startNvidiaUsage();
@@ -334,8 +335,10 @@ void Gpu::startNvidiaUsage() {
const qreal usage = const qreal usage =
parts.at(0).trimmed().toDouble(&ok1) / 100.0; parts.at(0).trimmed().toDouble(&ok1) / 100.0;
const qreal temp = parts.at(1).trimmed().toDouble(&ok2); const qreal temp = parts.at(1).trimmed().toDouble(&ok2);
const qreal memUsed = parts.at(2).trimmed().toDouble(&ok3); const qreal memUsed =
const qreal memTotal = parts.at(3).trimmed().toDouble(&ok4); parts.at(2).trimmed().toDouble(&ok3) * 1024;
const qreal memTotal =
parts.at(3).trimmed().toDouble(&ok4) * 1024;
if (ok1 && std::abs(usage - m_percentage) > 0.0001) { if (ok1 && std::abs(usage - m_percentage) > 0.0001) {
m_percentage = usage; m_percentage = usage;
+151 -6
View File
@@ -5,13 +5,17 @@
#include <QtQuick/qquickwindow.h> #include <QtQuick/qquickwindow.h>
#include <qcontainerfwd.h> #include <qcontainerfwd.h>
#include <qdir.h> #include <qdir.h>
#include <qdiriterator.h>
#include <qfileinfo.h> #include <qfileinfo.h>
#include <qfuturewatcher.h> #include <qfuturewatcher.h>
#include <qjsprimitivevalue.h> #include <qjsprimitivevalue.h>
#include <qloggingcategory.h> #include <qloggingcategory.h>
#include <qregularexpression.h>
#include <qqmlengine.h> #include <qqmlengine.h>
#include <qfile.h> #include <qfile.h>
#include "util/metaenum.hpp"
Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg) Q_LOGGING_CATEGORY(lcZUtils, "ZShell.cutils", QtInfoMsg)
namespace ZShell { namespace ZShell {
@@ -171,13 +175,112 @@ qreal ZUtils::clamp(qreal value, qreal min, qreal max) {
return qBound(min, value, max); return qBound(min, value, max);
} }
QString ZUtils::settingsIndex() { QString ZUtils::enumToString(
QFile file(QStringLiteral(":/qt/qml/ZShell/settings-index.json")); QObject* target, const QString& property, const QVariant& value) {
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) { if (!target) {
qCWarning(lcZUtils) << "Failed to open embedded settings index"; qCWarning(lcZUtils) << "enumToString: a target is required";
return QString(); return {};
} }
return QString::fromUtf8(file.readAll());
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;
} }
#ifndef ZSHELL_VERSION #ifndef ZSHELL_VERSION
@@ -192,4 +295,46 @@ QString ZUtils::qtVersion() const {
return QStringLiteral(QT_VERSION_STR); 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 } // namespace ZShell
+20 -1
View File
@@ -4,6 +4,8 @@
#include <qcontainerfwd.h> #include <qcontainerfwd.h>
#include <qobject.h> #include <qobject.h>
#include <qqmlintegration.h> #include <qqmlintegration.h>
#include <qtmetamacros.h>
#include <qvariant.h>
namespace ZShell { namespace ZShell {
@@ -32,7 +34,24 @@ class ZUtils : public QObject {
Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max); Q_INVOKABLE static qreal clamp(qreal value, qreal min, qreal max);
Q_INVOKABLE static QString settingsIndex(); 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);
[[nodiscard]] QString version() const; [[nodiscard]] QString version() const;
[[nodiscard]] QString qtVersion() const; [[nodiscard]] QString qtVersion() const;
+14
View File
@@ -0,0 +1,14 @@
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
@@ -0,0 +1,55 @@
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
@@ -0,0 +1,10 @@
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
@@ -0,0 +1,31 @@
#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
+13 -6
View File
@@ -15,7 +15,6 @@ It includes a configurable top bar, launcher, notifications daemon + sidebar, wa
## Highlights ## Highlights
- Multi-window shell layout driven by `Drawers/Windows.qml` and panel/interactions wrappers - 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 - 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 - 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 - Dynamic Material 3 palette generation from wallpaper, optional template rendering, terminal sequence application
@@ -47,16 +46,25 @@ Core requirements:
- Hyprland (Wayland session integration) - Hyprland (Wayland session integration)
- Python 3 for scheme/wallpaper tooling - Python 3 for scheme/wallpaper tooling
Make sure to have the newest Quickshell version! As of writing, version `0.2.0.r136.gfb08ece-1`. 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`.
Used by major features (install as needed for your setup): Used by major features (install as needed for your setup):
- `app2unit` (launcher app execution) - `app2unit` (launcher app execution)
- `nmcli` (network integration) - `nmcli` (network integration)
- `brightnessctl`, `ddcutil` (brightness controls) - `brightnessctl`, `ddcutil` (brightness controls)
- `wl-copy`, `swappy` (picker/screenshot flow) - `wl-copy` (clipboard integration)
- `libqalculate` (launcher calculator)
- `PipeWire` + audio stack + `aubio`/`cava` paths for media visualization - `PipeWire` + audio stack + `aubio`/`cava` paths for media visualization
- `libqalculate` (launcher calculator)
- `gsettings` (optional GTK dark/light mode sync) - `gsettings` (optional GTK dark/light mode sync)
## Build and Install ## Build and Install
@@ -180,7 +188,7 @@ Important state/cache files:
- `~/.local/state/zshell/apps.sqlite` - `~/.local/state/zshell/apps.sqlite`
- `~/.cache/zshell/` - `~/.cache/zshell/`
Config is hot-reloaded and saved through `Config/Config.qml` serializers. Top-level sections include: Config is hot-reloaded and saved through the `Config` singleton. Top-level sections include:
- `general` - `general`
- `appearance` - `appearance`
@@ -265,7 +273,6 @@ Note: Template rendering (Jinja2) applies generated colors to `~/.config/zshell/
### `screenshot` — area picker ### `screenshot` — area picker
- `start` — open interactive area picker - `start` — open interactive area picker
- `start-freeze` — freeze screen then pick
### `wallpaper` — wallpaper management ### `wallpaper` — wallpaper management
-452
View File
@@ -1,452 +0,0 @@
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
@@ -0,0 +1,450 @@
.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,
};
}