Watch
1
0
Fork
You've already forked souveraine
0

surfaces/quickshell: bring the phone shell under the surface tree

Dock fan-out stacks, drag-to-combine, pill gesture rewrite, and the ii
patch set (TaskbarApps stacks API, Config dock.stacks schema) — pulled
from the live phone and made canonical here. deploy.sh grew a manifest
and a --phone mode: rsync the surface over, symlink ii into it, so live
edits land in a git tree instead of drifting.
This commit is contained in:
Fimeg 2026-07-12 08:57:10 -04:00
commit 037dc06922
13 changed files with 2982 additions and 0 deletions

View file

@ -0,0 +1,98 @@
# Souveraine quickshell surface
The desktop shell as a Souveraine surface — the primary visual frontend for
SouveraineOS, with the TUI remaining the dive-in instrument.
## Architecture
- `services/Souveraine.qml` — the substrate singleton. The ONE server
connection every shell module hangs off: agent inventory, conversation
lifecycle, the SSE turn stream (raw events re-emitted via
`streamEvent(var)`), the backchannel (`cancelTurn()` / `interject(text)`),
and the desktop sensorium — every send carries ambient context (active
window, open apps, cursor position) so she perceives the room she is being
spoken to in. Device sensors (SouveraineOS positional data from the Pixel
3 kernel path) extend `collectAmbient()`.
- `services/Ai.qml` — ii-compat adapter. Keeps the API the illogical-impulse
sidebar expects; owns no transport. Shapes wire events into the message
objects the existing chat UI renders.
- `modules/` (coming) — presence (portrait PNGs from memfs, posture state
machine), cockpit (subconscious pane), agents (masterdetail manager),
settings, schedules. Each subscribes to the Souveraine singleton.
## What changes
- "Models" in the sidebar are **Souveraine agents** (`GET /v1/agents`).
Picking one starts a conversation with that agent — memory, sensors,
subconscious and all.
- Messages stream over the server's SSE endpoint
(`POST /v1/conversations/:id/messages`), authenticated with the agent's
bearer token from `~/.souveraine/server/agents/<id>/api_token`.
- Subconscious **surfacings**, **reflection**, and **archivist** pressure
render in the chat as interface notes (dedicated widgets later).
- Reasoning and sensor activity render inside collapsible `<think>` blocks.
- Keys/providers/temperature are owned by `souveraine.toml` — the sidebar's
`/key` and `/temp` commands now just point there. The keyring path is dead.
- Token pressure is fetched after each turn from
`GET /v1/conversations/:id/tokens`.
## Portability (KDE / non-Hyprland)
`Souveraine.qml` itself is compositor-agnostic: quickshell runs on any
wlroots-ish Wayland compositor and KWin; window sensing uses the
foreign-toplevel protocol (KWin implements it); the cursor read tries
`hyprctl`, then `kdotool`, then degrades to nothing — ambient never blocks
a send. Server autostart is desktop-neutral (systemd user unit, nohup
fallback), so opening any surface summons her.
What is NOT portable yet is the chrome: the chat UI is illogical-impulse's
sidebar. The path for "I run KDE, can I use this?" is a standalone
quickshell config (own ShellRoot + a window hosting the chat/presence
modules) that ships `Souveraine.qml` unchanged — planned once the modules
stop being ii-embedded. Same service, same mappings, different shell.
## Deploy
```bash
./deploy.sh # backs up upstream Ai.qml, symlinks ours in
./deploy.sh -u # restore upstream
```
Requires the server: `souveraine server` (default http://127.0.0.1:8484,
override with `ai.souveraineUrl` in the ii config).
## Wire contract
The server's SSE layer is a full mirror of `BackendEvent` (see
`src/api/models.rs::StreamEvent` — exhaustive `From` impls both ways, so a
new engine event is a compile error at the seam, not a silent skip). The
surface consumes the personification channel: subconscious tokens buffer and
flush as one bubble when the N+1 pass ends (`subconscious_pass`), halts land
as body signals, interstitials render by register (cenno = quiet aside,
her_voice = gutter passage), `primary_complete` releases the input while the
stream stays open for the subconscious, and `context_pressure` drives the
live token counter. `atmosphere`/`outfit`/`itinerary` are logged, awaiting
their shell-chrome layer.
Server-side, the backchannel and verbs exist for every surface:
`POST /v1/conversations/:id/cancel` (interrupt, `*[raised hand]*`
semantics), `.../interject` (mid-turn notes, queued between turns),
`GET .../messages` (transcript backfill), `POST .../fork` (`/btw`
side-quests). `SendMessageRequest.ambient` injects the sensorium note.
RemoteBackend rides all of it, so TUI remote mode gained cancel/interject/
fork/resume in the same stroke.
## Not yet wired
- Sidebar UI hooks for cancel (Esc) and interject (type-while-busy) — the
service functions exist, the ii chat input doesn't call them yet
- Conversation resume in the sidebar (server verb exists; surface always
starts fresh)
- Atmosphere/outfit/itinerary driving actual shell chrome (events arrive;
modules pending)
- File/image attachments (server has an image path; surface doesn't use it yet)
- Regenerate (Souveraine conversations are forward-only by doctrine)
- "Blank LLM mode" — a memoryless passthrough agent for throwaway questions;
needs a server-side agent flavor first
- Dedicated widgets for surfacing/subconscious bubbles instead of interface
notes

76
surfaces/quickshell/deploy.sh Executable file
View file

@ -0,0 +1,76 @@
#!/usr/bin/env bash
# Deploy the Souveraine quickshell surface over illogical-impulse.
# Overlay pattern: full replacement files, SYMLINKED so this repo checkout
# stays the source of truth — a "live edit" in ~/.config is an edit to the
# repo tree, visible in git status. Idempotent; -u restores upstream.
#
# deploy.sh symlink the surface into this machine's ii config
# deploy.sh -u restore upstream files, remove our additions
# deploy.sh --phone rsync this surface to the phone and deploy there
# (tree lands in ~/souveraine-surfaces/quickshell)
set -euo pipefail
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
QS="${HOME}/.config/quickshell"
II="${QS}/ii"
# Manifest: "<repo-relative> <target-relative-to-~/.config/quickshell>"
# Files replacing an upstream ii file get a one-time .upstream backup;
# files with no upstream counterpart (new components) are just linked.
MANIFEST="
services/Souveraine.qml ii/services/Souveraine.qml
services/Ai.qml ii/services/Ai.qml
services/TaskbarApps.qml ii/services/TaskbarApps.qml
modules/common/Config.qml ii/modules/common/Config.qml
modules/ii/dock/Dock.qml ii/modules/ii/dock/Dock.qml
modules/ii/dock/DockApps.qml ii/modules/ii/dock/DockApps.qml
modules/ii/dock/DockAppButton.qml ii/modules/ii/dock/DockAppButton.qml
modules/ii/dock/DockButton.qml ii/modules/ii/dock/DockButton.qml
modules/ii/dock/DockSeparator.qml ii/modules/ii/dock/DockSeparator.qml
modules/ii/dock/DockStack.qml ii/modules/ii/dock/DockStack.qml
pill/shell.qml pill/shell.qml
"
PHONE_USB=casey@172.16.42.1
PHONE_DEST="souveraine-surfaces/quickshell"
if [[ "${1:-}" == "--phone" ]]; then
ssh_i=(ssh -i "$HOME/.ssh/ani" -o ConnectTimeout=5)
"${ssh_i[@]}" "$PHONE_USB" "mkdir -p ~/$PHONE_DEST"
rsync -a --delete -e "ssh -i $HOME/.ssh/ani" "$SRC/" "$PHONE_USB:$PHONE_DEST/"
"${ssh_i[@]}" "$PHONE_USB" "bash ~/$PHONE_DEST/deploy.sh"
echo "Deployed to phone. Quickshell hot-reloads; if the dock misbehaves: pkill -f 'qs -c ii' (autostart relaunches)."
exit 0
fi
if [[ "${1:-}" == "-u" || "${1:-}" == "--uninstall" ]]; then
while read -r rel target; do
[[ -z "$rel" ]] && continue
t="$QS/$target"
if [[ -f "$t.upstream" ]]; then
rm -f "$t"; mv "$t.upstream" "$t"
echo "restored $target"
elif [[ -L "$t" ]]; then
rm -f "$t"
echo "removed $target (no upstream)"
fi
done <<< "$MANIFEST"
exit 0
fi
[[ -d "$II" ]] || { echo "illogical-impulse config not found at $II" >&2; exit 1; }
mkdir -p "$QS/pill"
while read -r rel target; do
[[ -z "$rel" ]] && continue
src="$SRC/$rel"; t="$QS/$target"
[[ -f "$src" ]] || { echo "missing $src" >&2; exit 1; }
# Back up upstream once (a real file, not our own symlink)
if [[ -f "$t" && ! -L "$t" && ! -f "$t.upstream" ]]; then
cp "$t" "$t.upstream"
fi
ln -sf "$src" "$t"
done <<< "$MANIFEST"
echo "Souveraine quickshell surface deployed ($(echo "$MANIFEST" | grep -c .) files symlinked from $SRC)"
echo "Quickshell hot-reloads on change; restart with: pkill -f 'qs -c ii' && pkill -f 'qs -c pill'"

View file

@ -0,0 +1,637 @@
pragma Singleton
pragma ComponentBehavior: Bound
import QtQuick
import Quickshell
import Quickshell.Io
import qs.modules.common.functions
Singleton {
id: root
property string filePath: Directories.shellConfigPath
property alias options: configOptionsJsonAdapter
property bool ready: false
property int readWriteDelay: 50 // milliseconds
property bool blockWrites: false
function setNestedValue(nestedKey, value) {
let keys = nestedKey.split(".");
let obj = root.options;
let parents = [obj];
// Traverse and collect parent objects
for (let i = 0; i < keys.length - 1; ++i) {
if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") {
obj[keys[i]] = {};
}
obj = obj[keys[i]];
parents.push(obj);
}
// Convert value to correct type using JSON.parse when safe
let convertedValue = value;
if (typeof value === "string") {
let trimmed = value.trim();
if (trimmed === "true" || trimmed === "false" || !isNaN(Number(trimmed))) {
try {
convertedValue = JSON.parse(trimmed);
} catch (e) {
convertedValue = value;
}
}
}
obj[keys[keys.length - 1]] = convertedValue;
}
Timer {
id: fileReloadTimer
interval: root.readWriteDelay
repeat: false
onTriggered: {
configFileView.reload()
}
}
Timer {
id: fileWriteTimer
interval: root.readWriteDelay
repeat: false
onTriggered: {
configFileView.writeAdapter()
}
}
FileView {
id: configFileView
path: root.filePath
watchChanges: true
blockWrites: root.blockWrites
onFileChanged: fileReloadTimer.restart()
onAdapterUpdated: fileWriteTimer.restart()
onLoaded: root.ready = true
onLoadFailed: error => {
if (error == FileViewError.FileNotFound) {
writeAdapter();
}
}
JsonAdapter {
id: configOptionsJsonAdapter
property string panelFamily: "ii" // "ii", "waffle"
property JsonObject policies: JsonObject {
property int ai: 1 // 0: No | 1: Yes | 2: Local
property int weeb: 1 // 0: No | 1: Open | 2: Closet
}
property JsonObject ai: JsonObject {
property string systemPrompt: "## Style\n- Use casual tone, don't be formal!\n- Always be brief and to the point, unless asked otherwise\n- Don't repeat the user's question\n- Be approachable: Avoid using overly complicated, domain-specific terms and provide analogies when asked to explain a concept\n\n## Context (ignore when irrelevant)\n- You are a helpful and inspiring sidebar assistant on a {DISTRO} Linux system\n- Desktop environment: {DE}\n- Current date & time: {DATETIME}\n- Focused app: {WINDOWCLASS}\n\n## Presentation\n- Use Markdown features in your response: \n - **Bold** text to **highlight keywords** in your response\n - **Split long information into small sections** with h2 headers and a relevant emoji at the start of it (for example `## 🐧 Linux`). Bullet points are preferred over long paragraphs, unless you're offering writing support or instructed otherwise by the user.\n- Asked to compare different options? You should firstly use a table to compare the main aspects, then elaborate or include relevant comments from online forums *after* the table. Make sure to provide a final recommendation for the user's use case!\n- Use LaTeX formatting for mathematical and scientific notations whenever appropriate. Enclose all LaTeX '$$' delimiters. NEVER generate LaTeX code in a latex block unless the user explicitly asks for it. DO NOT use LaTeX for regular documents (resumes, letters, essays, CVs, etc.).\n\nThanks!\n"
property string tool: "functions" // search, functions, or none
property list<var> extraModels: [
{
"api_format": "openai", // Most of the time you want "openai". Use "gemini" for Google's models
"description": "This is a custom model. Edit the config to add more! | Anyway, this is DeepSeek R1 Distill LLaMA 70B",
"endpoint": "https://openrouter.ai/api/v1/chat/completions",
"homepage": "https://openrouter.ai/deepseek/deepseek-r1-distill-llama-70b:free", // Not mandatory
"icon": "spark-symbolic", // Not mandatory
"key_get_link": "https://openrouter.ai/settings/keys", // Not mandatory
"key_id": "openrouter",
"model": "deepseek/deepseek-r1-distill-llama-70b:free",
"name": "Custom: DS R1 Dstl. LLaMA 70B",
"requires_key": true
}
]
}
property JsonObject appearance: JsonObject {
property bool extraBackgroundTint: true
property int fakeScreenRounding: 2 // 0: None | 1: Always | 2: When not fullscreen
property JsonObject fonts: JsonObject {
property string main: "Google Sans Flex"
property string numbers: "Google Sans Flex"
property string title: "Google Sans Flex"
property string iconNerd: "JetBrains Mono NF"
property string monospace: "JetBrains Mono NF"
property string reading: "Readex Pro"
property string expressive: "Space Grotesk"
}
property JsonObject transparency: JsonObject {
property bool enable: false
property bool automatic: true
property real backgroundTransparency: 0.11
property real contentTransparency: 0.57
}
property JsonObject wallpaperTheming: JsonObject {
property bool enableAppsAndShell: true
property bool enableQtApps: true
property bool enableTerminal: true
property JsonObject terminalGenerationProps: JsonObject {
property real harmony: 0.6
property real harmonizeThreshold: 100
property real termFgBoost: 0.35
property bool forceDarkMode: false
}
}
property JsonObject palette: JsonObject {
property string type: "auto" // Allowed: auto, scheme-content, scheme-expressive, scheme-fidelity, scheme-fruit-salad, scheme-monochrome, scheme-neutral, scheme-rainbow, scheme-tonal-spot
property string accentColor: ""
}
}
property JsonObject audio: JsonObject {
// Values in %
property JsonObject protection: JsonObject {
// Prevent sudden bangs
property bool enable: false
property real maxAllowedIncrease: 10
property real maxAllowed: 99
}
}
property JsonObject apps: JsonObject {
property string bluetooth: "kcmshell6 kcm_bluetooth"
property string changePassword: "kitty -1 --hold=yes fish -i -c 'passwd'"
property string network: "kcmshell6 kcm_networkmanagement"
property string manageUser: "kcmshell6 kcm_users"
property string networkEthernet: "kcmshell6 kcm_networkmanagement"
property string taskManager: "plasma-systemmonitor --page-name Processes"
property string terminal: "kitty -1" // This is only for shell actions
property string update: "kitty -1 --hold=yes fish -i -c 'pkexec pacman -Syu'"
property string volumeMixer: `~/.config/hypr/hyprland/scripts/launch_first_available.sh "pavucontrol-qt" "pavucontrol"`
}
property JsonObject background: JsonObject {
property JsonObject widgets: JsonObject {
property JsonObject clock: JsonObject {
property bool enable: true
property bool showOnlyWhenLocked: false
property string placementStrategy: "leastBusy" // "free", "leastBusy", "mostBusy"
property real x: 100
property real y: 100
property string style: "cookie" // Options: "cookie", "digital"
property string styleLocked: "cookie" // Options: "cookie", "digital"
property JsonObject cookie: JsonObject {
property bool aiStyling: false
property int sides: 14
property string dialNumberStyle: "full" // Options: "dots" , "numbers", "full" , "none"
property string hourHandStyle: "fill" // Options: "classic", "fill", "hollow", "hide"
property string minuteHandStyle: "medium" // Options "classic", "thin", "medium", "bold", "hide"
property string secondHandStyle: "dot" // Options: "dot", "line", "classic", "hide"
property string dateStyle: "bubble" // Options: "border", "rect", "bubble" , "hide"
property bool timeIndicators: true
property bool hourMarks: false
property bool dateInClock: true
property bool constantlyRotate: false
property bool useSineCookie: false
}
property JsonObject digital: JsonObject {
property bool adaptiveAlignment: true
property bool showDate: true
property bool animateChange: true
property bool vertical: false
property JsonObject font: JsonObject {
property string family: "Google Sans Flex"
property real weight: 350
property real width: 100
property real size: 90
property real roundness: 0
}
}
property JsonObject quote: JsonObject {
property bool enable: false
property string text: ""
}
}
property JsonObject weather: JsonObject {
property bool enable: false
property string placementStrategy: "free" // "free", "leastBusy", "mostBusy"
property real x: 400
property real y: 100
}
}
property string wallpaperPath: ""
property string thumbnailPath: ""
property bool hideWhenFullscreen: true
property JsonObject parallax: JsonObject {
property bool vertical: false
property bool autoVertical: false
property bool enableWorkspace: false
property real workspaceZoom: 1.07 // Relative to wallpaper size
property bool enableSidebar: false
property real widgetsFactor: 1.2
}
}
property JsonObject bar: JsonObject {
property JsonObject autoHide: JsonObject {
property bool enable: false
property int hoverRegionWidth: 2
property bool pushWindows: false
property JsonObject showWhenPressingSuper: JsonObject {
property bool enable: true
property int delay: 140
}
}
property bool bottom: false // Instead of top
property int cornerStyle: 0 // 0: Hug | 1: Float | 2: Plain rectangle
property bool floatStyleShadow: true // Show shadow behind bar when cornerStyle == 1 (Float)
property bool borderless: false // true for no grouping of items
property string topLeftIcon: "spark" // Options: "distro" or any icon name in ~/.config/quickshell/ii/assets/icons
property bool showBackground: true
property bool verbose: true
property bool vertical: false
property JsonObject resources: JsonObject {
property bool alwaysShowSwap: true
property bool alwaysShowCpu: true
property int memoryWarningThreshold: 95
property int swapWarningThreshold: 85
property int cpuWarningThreshold: 90
}
property list<string> screenList: [] // List of names, like "eDP-1", find out with 'hyprctl monitors' command
property JsonObject utilButtons: JsonObject {
property bool showScreenSnip: true
property bool showColorPicker: false
property bool showMicToggle: false
property bool showKeyboardToggle: true
property bool showDarkModeToggle: true
property bool showPerformanceProfileToggle: false
property bool showScreenRecord: false
}
property JsonObject workspaces: JsonObject {
property bool monochromeIcons: true
property int shown: 10
property bool showAppIcons: true
property bool alwaysShowNumbers: false
property int showNumberDelay: 300 // milliseconds
property list<string> numberMap: ["1", "2"] // Characters to show instead of numbers on workspace indicator
property bool useNerdFont: false
}
property JsonObject weather: JsonObject {
property bool enable: false
property bool enableGPS: true // gps based location
property string city: "" // When 'enableGPS' is false
property bool useUSCS: false // Instead of metric (SI) units
property int fetchInterval: 10 // minutes
}
property JsonObject indicators: JsonObject {
property JsonObject notifications: JsonObject {
property bool showUnreadCount: false
}
}
property JsonObject tooltips: JsonObject {
property bool clickToShow: false
}
}
property JsonObject battery: JsonObject {
property int low: 20
property int critical: 5
property int full: 101
property bool automaticSuspend: true
property int suspend: 3
}
property JsonObject calendar: JsonObject {
property string locale: "en-GB"
}
property JsonObject cheatsheet: JsonObject {
// Use a nerdfont to see the icons
// 0: 󰖳 | 1: 󰌽 | 2: 󰘳 | 3: | 4: 󰨡
// 5: | 6: | 7: 󰣇 | 8: | 9:
// 10: | 11: | 12: | 13: | 14: 󱄛
property string superKey: ""
property bool useMacSymbol: false
property bool splitButtons: false
property bool useMouseSymbol: false
property bool useFnSymbol: false
property JsonObject fontSize: JsonObject {
property int key: Appearance.font.pixelSize.smaller
property int comment: Appearance.font.pixelSize.smaller
}
}
property JsonObject conflictKiller: JsonObject {
property bool autoKillNotificationDaemons: false
property bool autoKillTrays: false
}
property JsonObject crosshair: JsonObject {
// Valorant crosshair format. Use https://www.vcrdb.net/builder
property string code: "0;P;d;1;0l;10;0o;2;1b;0"
}
property JsonObject dock: JsonObject {
property bool enable: false
property bool monochromeIcons: true
property real height: 60
property real hoverRegionHeight: 2
property bool pinnedOnStartup: false
property bool hoverToReveal: true // When false, only reveals on empty workspace
property list<string> pinnedApps: [ // IDs of pinned entries
"org.kde.dolphin", "kitty",]
property list<string> ignoredAppRegexes: []
// Fan-out stacks (macOS "Fan" mode). Each entry is
// "stackId|appId,appId,appId" phone-local, no Phosh sync.
// stackId is a display label; members render as an arc.
property list<string> stacks: []
}
property JsonObject interactions: JsonObject {
property JsonObject scrolling: JsonObject {
property bool fasterTouchpadScroll: false // Enable faster scrolling with touchpad
property int mouseScrollDeltaThreshold: 120 // delta >= this then it gets detected as mouse scroll rather than touchpad
property int mouseScrollFactor: 120
property int touchpadScrollFactor: 450
}
property JsonObject deadPixelWorkaround: JsonObject { // Hyprland leaves out 1 pixel on the right for interactions
property bool enable: false
}
}
property JsonObject language: JsonObject {
property string ui: "auto" // UI language. "auto" for system locale, or specific language code like "zh_CN", "en_US"
property JsonObject translator: JsonObject {
property string engine: "auto" // Run `trans -list-engines` for available engines. auto should use google
property string targetLanguage: "auto" // Run `trans -list-all` for available languages
property string sourceLanguage: "auto"
}
}
property JsonObject launcher: JsonObject {
property list<string> pinnedApps: [ "org.kde.dolphin", "kitty", "cmake-gui"]
}
property JsonObject light: JsonObject {
property JsonObject night: JsonObject {
property bool automatic: true
property string from: "19:00" // Format: "HH:mm", 24-hour time
property string to: "06:30" // Format: "HH:mm", 24-hour time
property int colorTemperature: 5000
}
property JsonObject antiFlashbang: JsonObject {
property bool enable: false
}
}
property JsonObject lock: JsonObject {
property bool useHyprlock: false
property bool launchOnStartup: false
property JsonObject blur: JsonObject {
property bool enable: true
property real radius: 100
property real extraZoom: 1.1
}
property bool centerClock: true
property bool showLockedText: true
property JsonObject security: JsonObject {
property bool unlockKeyring: true
property bool requirePasswordToPower: false
}
property bool materialShapeChars: true
}
property JsonObject media: JsonObject {
// Attempt to remove dupes (the aggregator playerctl one and browsers' native ones when there's plasma browser integration)
property bool filterDuplicatePlayers: true
}
property JsonObject networking: JsonObject {
property string userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
}
property JsonObject notifications: JsonObject {
property int timeout: 7000
property JsonObject monitor: JsonObject {
property bool enable: false
property string name: "" // Name of the monitor to show notifications on, like "eDP-1". Find out with 'hyprctl monitors' command
}
}
property JsonObject osd: JsonObject {
property int timeout: 1000
}
property JsonObject osk: JsonObject {
property string layout: "qwerty_full"
property bool pinnedOnStartup: false
}
property JsonObject overlay: JsonObject {
property bool openingZoomAnimation: true
property bool darkenScreen: true
property real clickthroughOpacity: 0.8
property JsonObject floatingImage: JsonObject {
property string imageSource: "https://media.tenor.com/H5U5bJzj3oAAAAAi/kukuru.gif"
property real scale: 0.5
}
}
property JsonObject overview: JsonObject {
property bool enable: true
property real scale: 0.18 // Relative to screen size
property real rows: 2
property real columns: 5
property bool orderRightLeft: false
property bool orderBottomUp: false
property bool centerIcons: true
}
property JsonObject regionSelector: JsonObject {
property JsonObject targetRegions: JsonObject {
property bool windows: true
property bool layers: false
property bool content: true
property bool showLabel: false
property real opacity: 0.3
property real contentRegionOpacity: 0.8
property int selectionPadding: 5
}
property JsonObject rect: JsonObject {
property bool showAimLines: true
}
property JsonObject circle: JsonObject {
property int strokeWidth: 6
property int padding: 10
}
property JsonObject annotation: JsonObject {
property bool useSatty: false
}
}
property JsonObject resources: JsonObject {
property int updateInterval: 3000
property int historyLength: 60
}
property JsonObject tray: JsonObject {
property bool monochromeIcons: true
property bool showItemId: false
property bool invertPinnedItems: true // Makes the below a whitelist for the tray and blacklist for the pinned area
property list<var> pinnedItems: [ "Fcitx" ]
property bool filterPassive: true
}
property JsonObject musicRecognition: JsonObject {
property int timeout: 16
property int interval: 4
}
property JsonObject search: JsonObject {
property int nonAppResultDelay: 30 // This prevents lagging when typing
property string engineBaseUrl: "https://www.google.com/search?q="
property list<string> excludedSites: ["quora.com", "facebook.com"]
property bool sloppy: false // Uses levenshtein distance based scoring instead of fuzzy sort. Very weird.
property JsonObject prefix: JsonObject {
property bool showDefaultActionsWithoutPrefix: true
property string action: "/"
property string app: ">"
property string clipboard: ";"
property string emojis: ":"
property string math: "="
property string shellCommand: "$"
property string webSearch: "?"
}
property JsonObject imageSearch: JsonObject {
property string imageSearchEngineBaseUrl: "https://lens.google.com/uploadbyurl?url="
property bool useCircleSelection: false
}
}
property JsonObject sidebar: JsonObject {
property bool keepRightSidebarLoaded: true
property JsonObject translator: JsonObject {
property bool enable: false
property int delay: 300 // Delay before sending request. Reduces (potential) rate limits and lag.
}
property JsonObject ai: JsonObject {
property bool textFadeIn: false
}
property JsonObject booru: JsonObject {
property bool allowNsfw: false
property string defaultProvider: "yandere"
property int limit: 20
property JsonObject zerochan: JsonObject {
property string username: "[unset]"
}
}
property JsonObject cornerOpen: JsonObject {
property bool enable: true
property bool bottom: false
property bool valueScroll: true
property bool clickless: false
property int cornerRegionWidth: 250
property int cornerRegionHeight: 5
property bool visualize: false
property bool clicklessCornerEnd: true
property int clicklessCornerVerticalOffset: 1
}
property JsonObject quickToggles: JsonObject {
property string style: "android" // Options: classic, android
property JsonObject android: JsonObject {
property int columns: 5
property list<var> toggles: [
{ "size": 2, "type": "network" },
{ "size": 2, "type": "bluetooth" },
{ "size": 1, "type": "idleInhibitor" },
{ "size": 1, "type": "mic" },
{ "size": 2, "type": "audio" },
{ "size": 2, "type": "nightLight" }
]
}
}
property JsonObject quickSliders: JsonObject {
property bool enable: false
property bool showMic: false
property bool showVolume: true
property bool showBrightness: true
}
}
property JsonObject screenRecord: JsonObject {
property string savePath: Directories.videos.replace("file://","") // strip "file://"
}
property JsonObject screenSnip: JsonObject {
property string savePath: "" // only copy to clipboard when empty
}
property JsonObject sounds: JsonObject {
property bool battery: false
property bool pomodoro: false
property string theme: "freedesktop"
}
property JsonObject time: JsonObject {
// https://doc.qt.io/qt-6/qtime.html#toString
property string format: "hh:mm"
property string shortDateFormat: "dd/MM"
property string dateWithYearFormat: "dd/MM/yyyy"
property string dateFormat: "ddd, dd/MM"
property JsonObject pomodoro: JsonObject {
property int breakTime: 300
property int cyclesBeforeLongBreak: 4
property int focus: 1500
property int longBreak: 900
}
property bool secondPrecision: false
}
property JsonObject updates: JsonObject {
property bool enableCheck: true
property int checkInterval: 120 // minutes
property int adviseUpdateThreshold: 75 // packages
property int stronglyAdviseUpdateThreshold: 200 // packages
}
property JsonObject wallpaperSelector: JsonObject {
property bool useSystemFileDialog: false
}
property JsonObject windows: JsonObject {
property bool showTitlebar: true // Client-side decoration for shell apps
property bool centerTitle: true
}
property JsonObject hacks: JsonObject {
property int arbitraryRaceConditionDelay: 20 // milliseconds
}
property JsonObject workSafety: JsonObject {
property JsonObject enable: JsonObject {
property bool wallpaper: false
property bool clipboard: false
}
property JsonObject triggerCondition: JsonObject {
property list<string> networkNameKeywords: ["airport", "cafe", "college", "company", "eduroam", "free", "guest", "public", "school", "university"]
property list<string> fileKeywords: ["anime", "booru", "ecchi", "hentai", "yande.re", "konachan", "breast", "nipples", "pussy", "nsfw", "spoiler", "girl"]
property list<string> linkKeywords: ["hentai", "porn", "sukebei", "hitomi.la", "rule34", "gelbooru", "fanbox", "dlsite"]
}
}
property JsonObject waffles: JsonObject {
// Some spots are kinda janky/awkward. Setting the following to
// false will make (some) stuff also be like that for accuracy.
// Example: the right-click menu of the Start button
property JsonObject tweaks: JsonObject {
property bool switchHandlePositionFix: true
property bool smootherMenuAnimations: true
property bool smootherSearchBar: true
}
property JsonObject bar: JsonObject {
property bool bottom: true
property bool leftAlignApps: false
}
property JsonObject actionCenter: JsonObject {
property list<string> toggles: [ "network", "bluetooth", "easyEffects", "powerProfile", "idleInhibitor", "nightLight", "darkMode", "antiFlashbang", "cloudflareWarp", "mic", "musicRecognition", "notifications", "onScreenKeyboard", "gameMode", "screenSnip", "colorPicker" ]
}
property JsonObject calendar: JsonObject {
property bool force2CharDayOfWeek: true
}
}
}
}
}

View file

@ -0,0 +1,255 @@
// Pixel3Arch patch to ii's stock Dock.qml (2026-07-07).
//
// Problem: the dock (pinned, exclusiveZone claiming real screen space) and
// wvkbd (the on-screen keyboard, see OnScreenKeyboard.qml) both anchor to
// the bottom edge and both claim exclusive zones their reservations
// stack instead of one giving way, so the keyboard/its focus-grab shield
// drift out of alignment with each other depending on what else is
// reserving space. Simplest real fix: the dock gets out of the way
// entirely while the keyboard is open, instead of trying to make the
// keyboard-side math account for wherever the dock happens to be.
//
// GlobalStates.oskOpen suppresses both reveal-when-idle and the pinned
// exclusive zone. Double-tapping the pill (see
// overlays/quickshell-pill/shell.qml) still pulses the dock visible for a
// few seconds via GlobalStates.dockRevealPulse, for reaching a dock app
// without first closing the keyboard.
import qs
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Controls
import QtQuick.Effects
import QtQuick.Layouts
import Quickshell.Io
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import Quickshell.Hyprland
Scope { // Scope
id: root
property bool pinned: Config.options?.dock.pinnedOnStartup ?? false
// Dock visibility as one explicit state instead of five OR'd booleans
// (Phosh's lesson: a small enum beats an overlapping-boolean knot).
// HIDDEN - fully tucked below the edge
// PEEK - hover strip / empty-desktop / pill-pulse showing it briefly
// SHOWN - visible but not claiming exclusive space
// PINNED - visible AND reserving an exclusive zone
// OSK-open and previewPopup-hover are *inputs* to this, not states.
enum DockState { Hidden, Peek, Shown, Pinned }
// The real pin, suppressed while the OSK is open unless the user is mid
// pill-pulse. Kept as a helper the state below reads.
property bool effectivePinned: root.pinned && (!GlobalStates.oskOpen || GlobalStates.dockRevealPulse)
// requestDockShow (previewPopup hover) is threaded up from DockApps via
// this alias so the state computation can see it in one place.
property bool previewShowing: false
function computeDockState() {
if (root.effectivePinned)
return Dock.DockState.Pinned;
if (GlobalStates.dockRevealPulse || root.previewShowing)
return Dock.DockState.Peek;
// Empty desktop (nothing focused) reveals the dock, unless the OSK
// took the bottom edge.
if (!GlobalStates.oskOpen && !ToplevelManager.activeToplevel?.activated)
return Dock.DockState.Peek;
return Dock.DockState.Hidden;
}
property int dockState: computeDockState()
// Two-stage bottom-edge swipe (hyprgrass edge:d:u -> `qs -c ii ipc call
// dock swipeUp`): first swipe pulses the dock visible for a few
// seconds; swiping again while it's showing (or when it's pinned
// anyway) escalates to the overview. Swipe with the overview open
// closes it again.
IpcHandler {
target: "dock"
function swipeUp(): void {
if (GlobalStates.overviewOpen) {
GlobalStates.overviewOpen = false;
return;
}
// Dock already visible (Peek/Shown/Pinned) -> escalate to overview.
if (root.dockState !== Dock.DockState.Hidden) {
GlobalStates.dockRevealPulse = false;
GlobalStates.overviewOpen = true;
} else {
GlobalStates.pulseDockReveal();
}
}
// Swipe down while the dock is showing dismisses it: clears the
// pulse and, if the overview is up, closes that instead.
function swipeDown(): void {
if (GlobalStates.overviewOpen) {
GlobalStates.overviewOpen = false;
return;
}
GlobalStates.dockRevealPulse = false;
}
function reveal(): void {
GlobalStates.pulseDockReveal();
}
}
// Settings-surface stub (entry point b). souveraine-settings doesn't
// exist yet; the long-hold "App settings" menu calls dockSettings.openApp
// here. For now it just pulses the dock and logs, so nothing errors and
// the future settings app has a stable IPC name to take over.
IpcHandler {
target: "dockSettings"
function openApp(appId: string): void {
console.log("[dockSettings] openApp stub for", appId,
"- souveraine-settings not yet installed");
GlobalStates.pulseDockReveal();
}
function open(): void {
console.log("[dockSettings] open stub - souveraine-settings not yet installed");
GlobalStates.pulseDockReveal();
}
}
Variants {
// For each monitor
model: Quickshell.screens
PanelWindow {
id: dockRoot
// Window
required property var modelData
screen: modelData
visible: !GlobalStates.screenLocked
// Visible for any non-Hidden state, plus the hover strip (which
// is a live pointer input, not a persisted state).
property bool reveal: root.dockState !== Dock.DockState.Hidden
|| (Config.options?.dock.hoverToReveal && dockMouseArea.containsMouse)
anchors {
bottom: true
left: true
right: true
}
exclusiveZone: root.dockState === Dock.DockState.Pinned ? implicitHeight - (Appearance.sizes.hyprlandGapsOut) - (Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut) : 0
implicitWidth: dockBackground.implicitWidth
WlrLayershell.namespace: "quickshell:dock"
color: "transparent"
implicitHeight: (Config.options?.dock.height ?? 70) + Appearance.sizes.elevationMargin + Appearance.sizes.hyprlandGapsOut
mask: Region {
item: dockMouseArea
}
MouseArea {
id: dockMouseArea
height: parent.height
anchors {
top: parent.top
topMargin: dockRoot.reveal ? 0 : Config.options?.dock.hoverToReveal ? (dockRoot.implicitHeight - Config.options.dock.hoverRegionHeight) : (dockRoot.implicitHeight + 1)
horizontalCenter: parent.horizontalCenter
}
implicitWidth: dockHoverRegion.implicitWidth + Appearance.sizes.elevationMargin * 2
hoverEnabled: true
Behavior on anchors.topMargin {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Item {
id: dockHoverRegion
anchors.fill: parent
implicitWidth: dockBackground.implicitWidth
Item { // Wrapper for the dock background
id: dockBackground
anchors {
top: parent.top
bottom: parent.bottom
horizontalCenter: parent.horizontalCenter
}
implicitWidth: dockRow.implicitWidth + 5 * 2
height: parent.height - Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut
StyledRectangularShadow {
target: dockVisualBackground
}
Rectangle { // The real rectangle that is visible
id: dockVisualBackground
property real margin: Appearance.sizes.elevationMargin
anchors.fill: parent
anchors.topMargin: Appearance.sizes.elevationMargin
anchors.bottomMargin: Appearance.sizes.hyprlandGapsOut
color: Appearance.colors.colLayer0
border.width: 1
border.color: Appearance.colors.colLayer0Border
radius: Appearance.rounding.large
}
RowLayout {
id: dockRow
anchors.top: parent.top
anchors.bottom: parent.bottom
anchors.horizontalCenter: parent.horizontalCenter
spacing: 3
property real padding: 5
VerticalButtonGroup {
Layout.topMargin: Appearance.sizes.hyprlandGapsOut // why does this work
GroupButton {
// Pin button
baseWidth: 35
baseHeight: 35
clickedWidth: baseWidth
clickedHeight: baseHeight + 20
buttonRadius: Appearance.rounding.normal
toggled: root.pinned
onClicked: root.pinned = !root.pinned
contentItem: MaterialSymbol {
text: "keep"
horizontalAlignment: Text.AlignHCenter
iconSize: Appearance.font.pixelSize.larger
color: root.pinned ? Appearance.m3colors.m3onPrimary : Appearance.colors.colOnLayer0
}
}
}
DockSeparator {}
DockApps {
id: dockApps
buttonPadding: dockRow.padding
onRequestDockShowChanged: root.previewShowing = requestDockShow
}
DockSeparator {}
DockButton {
Layout.fillHeight: true
onClicked: GlobalStates.overviewOpen = !GlobalStates.overviewOpen
topInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding
bottomInset: Appearance.sizes.hyprlandGapsOut + dockRow.padding
contentItem: MaterialSymbol {
anchors.fill: parent
horizontalAlignment: Text.AlignHCenter
font.pixelSize: parent.width / 2
text: "apps"
color: Appearance.colors.colOnLayer0
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,363 @@
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
DockButton {
id: root
property var appToplevel
property var appListRoot
property int lastFocused: -1
property real iconSize: 35
property real countDotWidth: 10
property real countDotHeight: 4
property bool appIsActive: appToplevel.toplevels.find(t => (t.activated == true)) !== undefined
readonly property bool isSeparator: appToplevel.appId === "SEPARATOR"
property var desktopEntry: DesktopEntries.heuristicLookup(appToplevel.appId)
enabled: !isSeparator
implicitWidth: isSeparator ? 1 : implicitHeight - topInset - bottomInset
Connections {
target: DesktopEntries
function onApplicationsChanged() {
root.desktopEntry = DesktopEntries.heuristicLookup(appToplevel.appId);
}
}
// --- Drag-to-combine (Tier 1) --------------------------------------
// Pattern copied from this codebase's OverviewWidget.qml (drag a window
// onto a workspace): a MouseArea with drag.target moves a ghost Item;
// Drag.active/source/hotSpot are set imperatively on press; the release
// reads which DropArea the drag ended over (appListRoot.dragTargetAppId,
// set by the DropArea's onEntered) and commits. NOT Qt's DropArea.onDropped
// the anchored-proxy version never moved, so no DropArea ever saw it.
// GNOME's dwell rule (500ms before a hover counts as "combine") gates the
// highlight so a quick brush-past doesn't read as an intended stack.
property bool formingStack: false // this icon is the current drop target, dwell fired
readonly property string dragAppId: appToplevel.appId
// The ghost that actually moves under the finger (real Drag source).
Item {
id: dragGhost
width: root.iconSize
height: root.iconSize
anchors.centerIn: parent
visible: dragMouse.dragging
Drag.hotSpot.x: width / 2
Drag.hotSpot.y: height / 2
z: 100
IconImage {
anchors.fill: parent
source: Quickshell.iconPath(AppSearch.guessIcon(root.appToplevel.appId), "image-missing")
opacity: 0.9
}
}
// Single interaction surface (mirrors OverviewWidget's dragArea owning
// all gestures): tap -> activate/launch, long-press -> menu, drag ->
// combine. Sits above the visual button; the button's own onClicked is
// routed here via root.activate() so nothing double-fires.
MouseArea {
id: dragMouse
anchors.fill: parent
enabled: !root.isSeparator
acceptedButtons: Qt.LeftButton | Qt.MiddleButton
hoverEnabled: true
property bool dragging: false
drag.target: dragGhost
drag.threshold: 12
pressAndHoldInterval: 450
drag.onActiveChanged: {
if (drag.active) {
dragging = true
dragGhost.Drag.source = root
dragGhost.Drag.active = true
}
}
onPressAndHold: (mouse) => {
if (!dragging && mouse.button === Qt.LeftButton) root.menuOpen = true
}
onReleased: (mouse) => {
if (dragging) {
const target = appListRoot.dragTargetAppId
const targetStack = appListRoot.dragTargetStackId
dragGhost.Drag.active = false
dragging = false
if (target && target.toLowerCase() !== root.appToplevel.appId.toLowerCase()) {
TaskbarApps.combineIntoStack(target, root.appToplevel.appId, targetStack)
}
appListRoot.dragTargetAppId = ""
appListRoot.dragTargetStackId = ""
return
}
if (root.menuOpen) return // long-press already handled it
if (mouse.button === Qt.MiddleButton) {
root.desktopEntry?.execute()
} else {
root.activate()
}
}
onCanceled: { dragging = false; dragGhost.Drag.active = false }
}
DropArea {
anchors.fill: parent
onEntered: (drag) => {
if (drag.source === root) return
dwellTimer.restart()
}
onExited: {
dwellTimer.stop()
root.formingStack = false
if (appListRoot.dragTargetAppId === root.appToplevel.appId) {
appListRoot.dragTargetAppId = ""
appListRoot.dragTargetStackId = ""
}
}
Timer {
id: dwellTimer
interval: 500 // GNOME's dwell intent, not accident
onTriggered: {
root.formingStack = true
appListRoot.dragTargetAppId = root.appToplevel.appId
appListRoot.dragTargetStackId = "" // plain app -> new stack
}
}
}
// Pre-commit preview: a rounded container fades in behind the icon once
// the dwell fires, so you SEE the stack forming before releasing.
Rectangle {
anchors.fill: parent
anchors.margins: -2
z: -1
radius: Appearance.rounding.normal
visible: opacity > 0
opacity: root.formingStack ? 1 : 0
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.55)
border.width: 1
border.color: Appearance.colors.colPrimary
Behavior on opacity { NumberAnimation { duration: 120 } }
scale: root.formingStack ? 1.08 : 1.0
Behavior on scale { NumberAnimation { duration: 120; easing.type: Easing.OutBack } }
}
Loader {
active: isSeparator
anchors {
fill: parent
topMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal
bottomMargin: dockVisualBackground.margin + dockRow.padding + Appearance.rounding.normal
}
sourceComponent: DockSeparator {}
}
Loader {
anchors.fill: parent
active: appToplevel.toplevels.length > 0
sourceComponent: MouseArea {
id: mouseArea
anchors.fill: parent
hoverEnabled: true
acceptedButtons: Qt.NoButton
onEntered: {
appListRoot.lastHoveredButton = root
appListRoot.buttonHovered = true
lastFocused = appToplevel.toplevels.length - 1
}
onExited: {
if (appListRoot.lastHoveredButton === root) {
appListRoot.buttonHovered = false
}
}
}
}
// Tap behaviour, called by the unified dragMouse handler above.
function activate() {
if (appToplevel.toplevels.length === 0) {
root.desktopEntry?.execute();
return;
}
lastFocused = (lastFocused + 1) % appToplevel.toplevels.length
appToplevel.toplevels[lastFocused].activate()
}
middleClickAction: () => {
root.desktopEntry?.execute();
}
altAction: () => {
TaskbarApps.togglePin(appToplevel.appId);
}
// Long-press -> per-app context menu (pin, stack membership, settings
// stub). Opened by dragMouse.onPressAndHold. This is settings-surface
// entry (b); "App settings" fires the dockSettings IPC stub.
property bool menuOpen: false
property real menuCenterX: 0
onMenuOpenChanged: {
if (menuOpen && QsWindow)
menuCenterX = QsWindow.mapFromItem(root, root.width / 2, 0).x;
}
PopupWindow {
id: ctxMenu
visible: root.menuOpen || ctxContent.opacity > 0
color: "transparent"
anchor {
window: root.QsWindow?.window ?? null
adjustment: PopupAdjustment.None
gravity: Edges.Top | Edges.Right
edges: Edges.Top | Edges.Left
}
implicitWidth: root.QsWindow?.window?.width ?? 1
implicitHeight: ctxColumn.implicitHeight + 18
MouseArea {
anchors.fill: parent
enabled: root.menuOpen
onClicked: root.menuOpen = false // tap-away closes
}
Rectangle {
id: ctxContent
anchors.bottom: parent.bottom
x: root.menuCenterX - width / 2
width: 200
height: ctxColumn.implicitHeight + 12
radius: Appearance.rounding.normal
color: Appearance.m3colors.m3surfaceContainer
border.width: 1
border.color: Appearance.colors.colLayer0Border
opacity: root.menuOpen ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 100 } }
readonly property string stackId: TaskbarApps.stackContaining(root.appToplevel.appId)
ColumnLayout {
id: ctxColumn
anchors.fill: parent
anchors.margins: 6
spacing: 2
component MenuItem: RippleButton {
Layout.fillWidth: true
implicitHeight: 34
property string label: ""
contentItem: StyledText {
anchors.fill: parent
anchors.leftMargin: 10
verticalAlignment: Text.AlignVCenter
text: parent.label
color: Appearance.m3colors.m3onSurface
font.pixelSize: Appearance.font.pixelSize.small
}
}
MenuItem {
label: TaskbarApps.isPinned(root.appToplevel.appId) ? "Unpin" : "Pin to dock"
onClicked: { TaskbarApps.togglePin(root.appToplevel.appId); root.menuOpen = false }
}
MenuItem {
visible: ctxContent.stackId === ""
Layout.preferredHeight: visible ? 34 : 0
label: "Add to new stack"
onClicked: {
// Name it "Stack N" with N one past the current count;
// renaming waits for the settings app.
const n = (Config.options?.dock.stacks?.length ?? 0) + 1;
TaskbarApps.addToStack("Stack " + n, root.appToplevel.appId);
root.menuOpen = false;
}
}
MenuItem {
visible: ctxContent.stackId !== ""
Layout.preferredHeight: visible ? 34 : 0
label: "Remove from stack"
onClicked: {
TaskbarApps.removeFromStack(ctxContent.stackId, root.appToplevel.appId);
root.menuOpen = false;
}
}
MenuItem {
label: "App settings…"
onClicked: {
// Stub: hand off to the future souveraine-settings app.
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call",
"dockSettings", "openApp", root.appToplevel.appId]);
root.menuOpen = false;
}
}
}
}
}
contentItem: Loader {
active: !isSeparator
sourceComponent: Item {
anchors.centerIn: parent
Loader {
id: iconImageLoader
anchors {
left: parent.left
right: parent.right
verticalCenter: parent.verticalCenter
}
active: !root.isSeparator
sourceComponent: IconImage {
source: Quickshell.iconPath(AppSearch.guessIcon(appToplevel.appId), "image-missing")
implicitSize: root.iconSize
}
}
Loader {
active: Config.options.dock.monochromeIcons
anchors.fill: iconImageLoader
sourceComponent: Item {
Desaturate {
id: desaturatedIcon
visible: false // There's already color overlay
anchors.fill: parent
source: iconImageLoader
desaturation: 0.8
}
ColorOverlay {
anchors.fill: desaturatedIcon
source: desaturatedIcon
color: ColorUtils.transparentize(Appearance.colors.colPrimary, 0.9)
}
}
}
RowLayout {
spacing: 3
anchors {
top: iconImageLoader.bottom
topMargin: 2
horizontalCenter: parent.horizontalCenter
}
Repeater {
model: Math.min(appToplevel.toplevels.length, 3)
delegate: Rectangle {
required property int index
radius: Appearance.rounding.full
implicitWidth: (appToplevel.toplevels.length <= 3) ?
root.countDotWidth : root.countDotHeight // Circles when too many
implicitHeight: root.countDotHeight
color: appIsActive ? Appearance.colors.colPrimary : ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
}
}
}
}
}
}

View file

@ -0,0 +1,253 @@
pragma ComponentBehavior: Bound
import Qt5Compat.GraphicalEffects
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
import Quickshell.Wayland
import qs.services
import qs.modules.common
import qs.modules.common.widgets
import qs.modules.common.functions
Item {
id: root
property real maxWindowPreviewHeight: 200
property real maxWindowPreviewWidth: 300
property real windowControlsHeight: 30
property real buttonPadding: 5
property Item lastHoveredButton: null
property bool buttonHovered: false
property bool requestDockShow: previewPopup.show
// Drag-to-combine target, shared across all dock buttons/stacks. A
// DropArea's 500ms dwell sets these; the dragged button reads them on
// release to decide the combine. dragTargetStackId non-empty means the
// drop target is an existing stack (add into it); empty means a plain
// app (make a new stack).
property string dragTargetAppId: ""
property string dragTargetStackId: ""
Layout.fillHeight: true
Layout.topMargin: Appearance.sizes.hyprlandGapsOut
implicitWidth: listView.implicitWidth
function popupCenterXForButton(button) {
if (!button || !root.QsWindow)
return 0;
return root.QsWindow.mapFromItem(button, button.width / 2, 0).x;
}
StyledListView {
id: listView
spacing: 2
orientation: ListView.Horizontal
anchors {
top: parent.top
bottom: parent.bottom
}
implicitWidth: contentWidth
Behavior on implicitWidth {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
model: ScriptModel {
objectProp: "appId"
values: TaskbarApps.apps
}
delegate: Loader {
required property var modelData
// Fan-out stack entries render as DockStack; everything else
// (pinned apps, running windows, separators) as DockAppButton.
sourceComponent: modelData.isStack ? stackComp : appComp
Component {
id: appComp
DockAppButton {
appToplevel: modelData
appListRoot: root
topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
}
}
Component {
id: stackComp
DockStack {
appToplevel: modelData
appListRoot: root
topInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
bottomInset: Appearance.sizes.hyprlandGapsOut + root.buttonPadding
}
}
}
}
PopupWindow {
id: previewPopup
property var appTopLevel: root.lastHoveredButton?.appToplevel
property bool shouldShow: (popupMouseArea.containsMouse || root.buttonHovered) && appTopLevel && appTopLevel.toplevels && appTopLevel.toplevels.length > 0
property bool show: false
property real cachedCenterX: 0
Connections {
target: root
function onLastHoveredButtonChanged() {
if (root.lastHoveredButton && root.QsWindow)
previewPopup.cachedCenterX = root.popupCenterXForButton(root.lastHoveredButton);
}
function onButtonHoveredChanged() {
if (root.buttonHovered && root.lastHoveredButton && root.QsWindow)
previewPopup.cachedCenterX = root.popupCenterXForButton(root.lastHoveredButton);
updateTimer.restart();
}
}
onShouldShowChanged: {
updateTimer.restart();
}
Timer {
id: updateTimer
interval: 100
onTriggered: {
previewPopup.show = previewPopup.shouldShow;
}
}
anchor {
window: root.QsWindow.window
adjustment: PopupAdjustment.None
gravity: Edges.Top | Edges.Right
edges: Edges.Top | Edges.Left
}
visible: popupBackground.opacity > 0
color: "transparent"
implicitWidth: root.QsWindow.window?.width ?? 1
implicitHeight: popupMouseArea.implicitHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2
MouseArea {
id: popupMouseArea
anchors.bottom: parent.bottom
implicitWidth: popupBackground.implicitWidth + Appearance.sizes.elevationMargin * 2
implicitHeight: root.maxWindowPreviewHeight + root.windowControlsHeight + Appearance.sizes.elevationMargin * 2
hoverEnabled: true
x: previewPopup.cachedCenterX - width / 2
StyledRectangularShadow {
target: popupBackground
opacity: previewPopup.show ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
}
Rectangle {
id: popupBackground
property real padding: 5
opacity: previewPopup.show ? 1 : 0
visible: opacity > 0
Behavior on opacity {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
clip: true
color: Appearance.m3colors.m3surfaceContainer
radius: Appearance.rounding.normal
anchors.bottom: parent.bottom
anchors.bottomMargin: Appearance.sizes.elevationMargin
anchors.horizontalCenter: parent.horizontalCenter
implicitHeight: previewRowLayout.implicitHeight + padding * 2
implicitWidth: previewRowLayout.implicitWidth + padding * 2
Behavior on implicitWidth {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
Behavior on implicitHeight {
animation: Appearance.animation.elementMoveFast.numberAnimation.createObject(this)
}
RowLayout {
id: previewRowLayout
anchors.centerIn: parent
Repeater {
model: ScriptModel {
values: previewPopup.appTopLevel?.toplevels ?? []
}
RippleButton {
id: windowButton
Layout.fillHeight: true
required property var modelData
padding: 0
middleClickAction: () => {
windowButton.modelData?.close();
}
onClicked: {
windowButton.modelData?.activate();
}
contentItem: ColumnLayout {
implicitWidth: screencopyView.implicitWidth
implicitHeight: screencopyView.implicitHeight
ButtonGroup {
contentWidth: parent.width - anchors.margins * 2
StyledText {
Layout.margins: 5
Layout.fillWidth: true
font.pixelSize: Appearance.font.pixelSize.small
text: windowButton.modelData?.title
elide: Text.ElideRight
color: Appearance.m3colors.m3onSurface
}
GroupButton {
id: closeButton
colBackground: ColorUtils.transparentize(Appearance.colors.colSurfaceContainer)
baseWidth: root.windowControlsHeight
baseHeight: root.windowControlsHeight
buttonRadius: Appearance.rounding.full
contentItem: MaterialSymbol {
anchors.centerIn: parent
horizontalAlignment: Text.AlignHCenter
text: "close"
iconSize: Appearance.font.pixelSize.normal
color: Appearance.m3colors.m3onSurface
}
onClicked: {
windowButton.modelData?.close();
}
}
}
Item {
Layout.fillWidth: true
Layout.fillHeight: true
implicitHeight: screencopyView.height
implicitWidth: screencopyView.width
ScreencopyView {
id: screencopyView
anchors.centerIn: parent
captureSource: windowButton.modelData
live: true
paintCursor: true
constraintSize: Qt.size(root.maxWindowPreviewWidth, root.maxWindowPreviewHeight)
layer.enabled: true
layer.effect: OpacityMask {
maskSource: Rectangle {
width: screencopyView.width
height: screencopyView.height
radius: Appearance.rounding.small
}
}
}
}
}
}
}
}
}
}
}
}

View file

@ -0,0 +1,13 @@
import qs.modules.common
import qs.modules.common.widgets
import QtQuick
import QtQuick.Layouts
RippleButton {
Layout.fillHeight: true
Layout.topMargin: Appearance.sizes.elevationMargin - Appearance.sizes.hyprlandGapsOut
implicitWidth: implicitHeight - topInset - bottomInset
buttonRadius: Appearance.rounding.normal
background.implicitHeight: 50
}

View file

@ -0,0 +1,11 @@
import qs.modules.common
import QtQuick
import QtQuick.Layouts
Rectangle {
Layout.topMargin: Appearance.sizes.elevationMargin + dockRow.padding + Appearance.rounding.normal
Layout.bottomMargin: Appearance.sizes.hyprlandGapsOut + dockRow.padding + Appearance.rounding.normal
Layout.fillHeight: true
implicitWidth: 1
color: Appearance.colors.colOutlineVariant
}

View file

@ -0,0 +1,250 @@
// DockStack macOS-Dock "Fan" mode for a stack of apps (2026-07-11).
//
// Collapsed: a single stacked-cards icon (the topmost member) with a count
// dot, like a pinned app. Tap or long-press to expand.
//
// Expanded: member icons arc UP off the dock along a polar curve. Angle
// sweeps ~90deg (straight up) to ~55deg across N members; radius grows per
// item so they don't overlap. Slide a thumb up the arc the icon under the
// finger scales/highlights; release launches it. Release off the arc (or a
// plain tap on empty space) collapses without launching.
//
// The arc lives in its own PopupWindow (like previewPopup in DockApps.qml)
// so it can draw ABOVE the dock's own bounds. Icons animate x/y from the
// collapsed origin out to their arc slots, driven by one `expanded` bool.
pragma ComponentBehavior: Bound
import qs.services
import qs.modules.common
import qs.modules.common.functions
import QtQuick
import QtQuick.Layouts
import Quickshell
import Quickshell.Widgets
DockButton {
id: root
property var appToplevel // the STACK entry (isStack === true)
property var appListRoot
property real iconSize: 35
property real countDotWidth: 10
property real countDotHeight: 4
// Arc geometry.
readonly property real arcRadiusBase: 46 // first item's distance up
readonly property real arcRadiusStep: 30 // extra distance per item
readonly property real arcAngleStart: 90 // degrees, straight up
readonly property real arcAngleEnd: 55 // degrees, last item leans out
readonly property var members: appToplevel?.members ?? []
property bool expanded: false
// Index highlighted by the current drag, or -1 for none.
property int highlightedIndex: -1
// Polar slot for member i (0-based), as an offset from the collapsed
// icon centre. y is negative = upward. Single-member stacks go straight up.
function arcAngleFor(i) {
if (members.length <= 1) return root.arcAngleStart;
const t = i / (members.length - 1);
return root.arcAngleStart + t * (root.arcAngleEnd - root.arcAngleStart);
}
function arcRadiusFor(i) {
return root.arcRadiusBase + i * root.arcRadiusStep;
}
function slotX(i) {
const a = arcAngleFor(i) * Math.PI / 180;
return arcRadiusFor(i) * Math.cos(a);
}
function slotY(i) {
const a = arcAngleFor(i) * Math.PI / 180;
return -arcRadiusFor(i) * Math.sin(a);
}
function collapse() {
root.expanded = false;
root.highlightedIndex = -1;
}
function launchMember(i) {
if (i < 0 || i >= members.length) return;
const entry = DesktopEntries.heuristicLookup(members[i]);
entry?.execute();
}
implicitWidth: implicitHeight - topInset - bottomInset
// Collapsed icon: topmost member, with a stacked-cards shadow behind it
// and a count dot showing how many are inside.
contentItem: Item {
Item {
id: collapsedStack
anchors.centerIn: parent
width: root.iconSize
height: root.iconSize
// Two offset "card" hints behind the front icon for the stacked look.
Rectangle {
anchors.centerIn: parent
anchors.horizontalCenterOffset: 3
anchors.verticalCenterOffset: -3
width: parent.width - 4
height: parent.height - 4
radius: Appearance.rounding.small
color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.75)
}
Rectangle {
anchors.centerIn: parent
anchors.horizontalCenterOffset: 1.5
anchors.verticalCenterOffset: -1.5
width: parent.width - 2
height: parent.height - 2
radius: Appearance.rounding.small
color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.55)
}
IconImage {
anchors.fill: parent
source: Quickshell.iconPath(AppSearch.guessIcon(root.members[0] ?? ""), "image-missing")
}
}
// Count dot(s) under the icon same pattern as DockAppButton.
RowLayout {
spacing: 3
anchors {
top: collapsedStack.bottom
topMargin: 2
horizontalCenter: parent.horizontalCenter
}
Repeater {
model: Math.min(root.members.length, 3)
delegate: Rectangle {
required property int index
radius: Appearance.rounding.full
implicitWidth: (root.members.length <= 3) ? root.countDotWidth : root.countDotHeight
implicitHeight: root.countDotHeight
color: ColorUtils.transparentize(Appearance.colors.colOnLayer0, 0.4)
}
}
}
}
// Tap expands; tapping again (while expanded) collapses.
onClicked: {
if (root.expanded) root.collapse();
else root.expanded = true;
}
// Horizontal centre of this button, in its window's coordinates
// recomputed when the popup opens (same trick as previewPopup).
property real cachedCenterX: 0
onExpandedChanged: {
if (expanded && QsWindow)
cachedCenterX = QsWindow.mapFromItem(root, root.width / 2, 0).x;
}
// The arc: a full-width PopupWindow anchored above the dock (same anchor
// pattern as previewPopup gravity Top on the dock window). The arc
// MouseArea floats at cachedCenterX so it sits over this stack icon.
PopupWindow {
id: arcPopup
visible: root.expanded || arcContent.opacity > 0
color: "transparent"
// Reach = furthest member's radius + icon + margin.
readonly property real reach: root.arcRadiusFor(Math.max(root.members.length - 1, 0)) + root.iconSize + 24
anchor {
window: root.QsWindow?.window ?? null
adjustment: PopupAdjustment.None
gravity: Edges.Top | Edges.Right
edges: Edges.Top | Edges.Left
}
implicitWidth: root.QsWindow?.window?.width ?? 1
implicitHeight: reach
MouseArea {
id: arcArea
anchors.bottom: parent.bottom
implicitWidth: arcPopup.reach * 2
implicitHeight: arcPopup.reach
x: root.cachedCenterX - width / 2
enabled: root.expanded
hoverEnabled: false
// Collapsed-icon origin inside this MouseArea = bottom centre.
readonly property real originX: width / 2
readonly property real originY: height - root.iconSize / 2
function indexUnder(px, py) {
var best = -1;
var bestD = 44; // px pick radius
for (var i = 0; i < root.members.length; i++) {
const cx = originX + root.slotX(i);
const cy = originY + root.slotY(i);
const d = Math.hypot(px - cx, py - cy);
if (d < bestD) { bestD = d; best = i; }
}
return best;
}
onPositionChanged: (mouse) => {
root.highlightedIndex = indexUnder(mouse.x, mouse.y);
}
onReleased: (mouse) => {
const i = indexUnder(mouse.x, mouse.y);
if (i >= 0) root.launchMember(i);
root.collapse();
}
onCanceled: root.collapse()
// A plain tap on empty popup space collapses.
onClicked: (mouse) => {
if (indexUnder(mouse.x, mouse.y) < 0) root.collapse();
}
Item {
id: arcContent
anchors.fill: parent
opacity: root.expanded ? 1 : 0
Behavior on opacity { NumberAnimation { duration: 120 } }
Repeater {
model: root.members.length
delegate: Item {
id: memberIcon
required property int index
width: root.iconSize
height: root.iconSize
readonly property bool highlighted: root.highlightedIndex === index
// Animate from the collapsed origin out to the slot.
x: (root.expanded
? arcArea.originX + root.slotX(index)
: arcArea.originX) - width / 2
y: (root.expanded
? arcArea.originY + root.slotY(index)
: arcArea.originY) - height / 2
scale: highlighted ? 1.25 : 1.0
z: highlighted ? 1 : 0
Behavior on x { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
Behavior on y { NumberAnimation { duration: 160; easing.type: Easing.OutCubic } }
Behavior on scale { NumberAnimation { duration: 90 } }
Rectangle {
anchors.fill: parent
anchors.margins: -4
radius: Appearance.rounding.normal
color: memberIcon.highlighted
? ColorUtils.transparentize(Appearance.colors.colPrimary, 0.6)
: "transparent"
}
IconImage {
anchors.fill: parent
source: Quickshell.iconPath(AppSearch.guessIcon(root.members[memberIcon.index] ?? ""), "image-missing")
}
}
}
}
}
}
}

View file

@ -0,0 +1,65 @@
// pill Phosh-style gesture bar for the Pixel 3.
// Gesture map (2026-07-11 rewrite):
// double-tap : toggle fullscreen (maximize, mode 1) of the active window
// swipe up : go home (empty ws)
// swipe down : dismiss the dock (ii "dock" swipeDown IPC)
// The long-press-for-keyboard binding was REMOVED from the pill; the OSK is
// still reachable via the 3-finger-swipe-up hyprgrass bind (hyprland.lua).
// Fullscreen mode 1 (maximize/borderless) is used, not mode 0 (true
// fullscreen), so it stays reversible and doesn't force clients to redraw
// their UI the phone-friendly default. Apps that want true fullscreen do
// it themselves (future advanced per-app toggles).
import Quickshell
import Quickshell.Wayland
import QtQuick
ShellRoot {
PanelWindow {
id: pillWin
anchors.bottom: true
implicitWidth: 200
implicitHeight: 26
margins.bottom: 0
// stick to the physical screen edge: ignore other surfaces'
// exclusive zones (dock, OSK) instead of being pushed up by them
exclusionMode: ExclusionMode.Ignore
color: "transparent"
WlrLayershell.layer: WlrLayer.Overlay
Rectangle {
id: pill
anchors.centerIn: parent
width: 150
height: 7
radius: 3.5
color: "#e6ffffff"
Behavior on width { NumberAnimation { duration: 120 } }
}
MouseArea {
anchors.fill: parent
property real startY: 0
property bool fired: false
onPressed: (mouse) => { startY = mouse.y; fired = false; pill.width = 170 }
onReleased: pill.width = 150
onCanceled: pill.width = 150
onPositionChanged: (mouse) => {
if (fired) return
if (startY - mouse.y > 35) {
fired = true
Quickshell.execDetached(["hyprctl", "dispatch", "workspace", "empty"])
} else if (mouse.y - startY > 35) {
fired = true
Quickshell.execDetached(["qs", "-c", "ii", "ipc", "call", "dock", "swipeDown"])
}
}
onDoubleClicked: {
fired = true
// Maximize/un-maximize the active window (Hyprland fullscreen
// mode 1). No-op with nothing focused.
Quickshell.execDetached(["hyprctl", "dispatch", "fullscreen", "1"])
}
}
}
}

View file

@ -0,0 +1,439 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common
import Quickshell
import Quickshell.Io
import QtQuick
import qs.services
import qs.services.ai
/**
* ii-compat adapter over the Souveraine singleton.
*
* Keeps the public API the illogical-impulse sidebar UI expects (models,
* messages, sendUserMessage, /key advice, ...) but owns no transport
* Souveraine.qml is the substrate connection. This file's job is shaping
* wire events into AiMessageData objects the existing chat UI renders.
*
* Lives in souveraine/surfaces/quickshell/, deployed over
* ~/.config/quickshell/ii/services/Ai.qml (see deploy.sh).
*/
Singleton {
id: root
property Component aiMessageComponent: AiMessageData {}
property Component aiModelComponent: AiModel {}
readonly property string interfaceRole: "interface"
signal responseFinished()
property var messageIDs: []
property var messageByID: ({})
// Keys are server-side; the UI's key gate must always pass.
readonly property bool currentModelHasApiKey: true
readonly property var apiKeysLoaded: true
property var postResponseHook
property real temperature: Persistent.states?.ai?.temperature ?? 0.5
property QtObject tokenCount: QtObject {
property int input: -1
property int output: -1
property int total: -1
}
function idForMessage(message) {
return Date.now().toString(36) + Math.random().toString(36).substr(2, 8);
}
property list<var> defaultPrompts: []
property list<var> userPrompts: []
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
property list<var> savedChats: []
property list<var> pendingFiles: []
// Tool selection is owned by the agent's sensorium; keep the UI happy.
property string currentTool: "souveraine"
property list<var> availableTools: ["souveraine"]
property var toolDescriptions: {
"souveraine": Translation.tr("Sensors are configured per-agent in Souveraine")
}
// Agents as models (projected from Souveraine.agents)
property var models: ({})
property var modelList: Object.keys(root.models)
property var currentModelId: Souveraine.currentAgentId
property var currentModel: models[currentModelId] || models[modelList[0]]
Connections {
target: Souveraine
function onAgentsRefreshed() {
const map = {};
Souveraine.agentList.forEach(id => {
const agent = Souveraine.agents[id];
map[id] = root.aiModelComponent.createObject(root, {
"name": agent.name,
"icon": "spark-symbolic",
"description": agent.description.length > 0 ? agent.description : Translation.tr("Souveraine agent"),
"endpoint": Souveraine.serverBase,
"model": id,
"requires_key": false,
});
});
root.models = map;
root.modelList = Object.keys(map);
// Restore persisted agent choice if it exists server-side
const persisted = Persistent.states?.ai?.model ?? "";
if (persisted.length > 0 && Souveraine.agents[persisted]) {
Souveraine.selectAgent(persisted);
}
}
function onServerUnreachable() {
root.addMessage(
Translation.tr("Souveraine server unreachable at %1\n\nStart it with:\n```bash\nsouveraine server\n```").arg(Souveraine.serverBase),
root.interfaceRole
);
}
function onStreamEvent(event) {
root.handleStreamEvent(event);
}
function onStreamClosed(exitCode) {
root.flushSubconscious();
if (root.streamingMessage && !root.streamingMessage.done) {
if (exitCode !== 0 && root.streamingMessage.content.length === 0) {
root.appendToStreaming(Translation.tr("Request failed (curl exit %1) — is the Souveraine server up at %2?").arg(exitCode).arg(Souveraine.serverBase));
}
root.finishStreaming();
}
}
}
// Streaming message shaping
property AiMessageData streamingMessage
property bool inThinkBlock: false
property string subconsciousBuffer: ""
function appendToStreaming(text) {
if (!root.streamingMessage) return;
root.streamingMessage.rawContent += text;
root.streamingMessage.content += text;
}
function flushSubconscious() {
if (root.subconsciousBuffer.length > 0) {
root.addMessage(Translation.tr("**Subconscious**\n\n%1").arg(root.subconsciousBuffer), root.interfaceRole);
root.subconsciousBuffer = "";
}
}
function finishStreaming() {
if (!root.streamingMessage) return;
if (root.inThinkBlock) {
root.appendToStreaming("\n</think>\n");
root.inThinkBlock = false;
}
root.streamingMessage.thinking = false;
root.streamingMessage.done = true;
if (root.postResponseHook) {
root.postResponseHook();
root.postResponseHook = null;
}
root.saveChat("lastSession");
root.responseFinished();
}
function handleStreamEvent(event) {
if (root.streamingMessage?.thinking && event.message_type !== "ping")
root.streamingMessage.thinking = false;
switch (event.message_type) {
case "assistant_message":
if (root.inThinkBlock) {
root.appendToStreaming("\n</think>\n");
root.inThinkBlock = false;
}
root.appendToStreaming(event.content);
break;
case "reasoning_message":
if (!root.inThinkBlock) {
root.appendToStreaming("\n<think>\n");
root.inThinkBlock = true;
}
root.appendToStreaming(event.content);
break;
case "tool_call_message": {
const call = event.tool_call;
root.appendToStreaming(`\n\n<think>\nsensor: ${call.function.name}(${call.function.arguments})\n</think>\n`);
break;
}
case "tool_return_message": {
const ret = event.tool_return;
root.appendToStreaming(`\n<think>\n[${ret.status}] ${ret.output}\n</think>\n`);
break;
}
case "interstitial":
// Her narration between gestures. Register decides how loud:
// cenno is a quiet aside, her_voice is a passage.
root.appendToStreaming(event.register === "her_voice"
? `\n\n> ${event.text}\n\n`
: `\n\n*${event.text}*\n\n`);
break;
case "souveraine_surfacing":
root.addMessage(Translation.tr("**Aster surfaces** (%1, %2)\n\n%3").arg(event.source).arg(event.priority).arg(event.content), root.interfaceRole);
break;
case "souveraine_reflection":
root.addMessage(Translation.tr("**Reflection**\n\n%1").arg(event.content), root.interfaceRole);
break;
case "souveraine_archivist":
root.addMessage(Translation.tr("**Archivist** (pressure %1%)\n\n%2").arg(Math.round(event.pressure * 100)).arg(event.synthesis), root.interfaceRole);
break;
case "compaction_warning":
root.addMessage(Translation.tr("**Context pressure** — tier %1, %2% full. She can feel the walls.").arg(event.tier).arg(Math.round(event.pressure * 100)), root.interfaceRole);
break;
case "context_pressure":
root.tokenCount.total = event.tokens;
break;
case "subconscious_token":
root.subconsciousBuffer += event.content;
break;
case "subconscious_pass":
if (!event.active) root.flushSubconscious();
break;
case "subconscious_halt":
root.addMessage(Translation.tr("**Halt** (%1) — %2").arg(event.severity).arg(event.reason), root.interfaceRole);
break;
case "inference_strain":
console.log(`[Souveraine] inference strain: attempt ${event.attempt}, status ${event.status}, model ${event.model}`);
break;
case "atmosphere":
case "outfit":
case "itinerary":
// Shell chrome hooks their modules subscribe to
// Souveraine.streamEvent directly; nothing to do here.
break;
case "primary_complete":
// Primary yields; subconscious presses on behind this.
root.finishStreaming();
break;
case "done":
if (root.streamingMessage && !root.streamingMessage.done) root.finishStreaming();
break;
case "ping":
// Liveness only not end-of-turn.
break;
}
}
// Message store
function addMessage(message, role) {
if (message.length === 0) return;
const aiMessage = aiMessageComponent.createObject(root, {
"role": role,
"content": message,
"rawContent": message,
"thinking": false,
"done": true,
});
const id = idForMessage(aiMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = aiMessage;
}
function removeMessage(index) {
if (index < 0 || index >= messageIDs.length) return;
const id = root.messageIDs[index];
root.messageIDs.splice(index, 1);
root.messageIDs = [...root.messageIDs];
delete root.messageByID[id];
}
function clearMessages() {
root.messageIDs = [];
root.messageByID = ({});
root.tokenCount.input = -1;
root.tokenCount.output = -1;
root.tokenCount.total = -1;
Souveraine.newConversation();
}
function sendUserMessage(message) {
if (message.length === 0) return;
root.addMessage(message, "user");
if (!Souveraine.send(message)) {
root.addMessage(Translation.tr("Souveraine server unreachable at %1 — start it with `souveraine server`").arg(Souveraine.serverBase), root.interfaceRole);
return;
}
/* Streaming assistant message; filled by handleStreamEvent */
root.inThinkBlock = false;
root.streamingMessage = root.aiMessageComponent.createObject(root, {
"role": "assistant",
"model": Souveraine.currentAgentId,
"content": "",
"rawContent": "",
"thinking": true,
"done": false,
});
const id = idForMessage(root.streamingMessage);
root.messageIDs = [...root.messageIDs, id];
root.messageByID[id] = root.streamingMessage;
}
// Model (agent) selection
function getModel() {
return models[currentModelId];
}
function setModel(modelId, feedback = true, setPersistentState = true) {
if (!modelId) modelId = ""
if (modelList.indexOf(modelId) === -1) {
const match = modelList.find(id =>
id.toLowerCase() === modelId.toLowerCase() ||
(models[id]?.name ?? "").toLowerCase() === modelId.toLowerCase());
if (!match) {
if (feedback) root.addMessage(Translation.tr("Unknown agent. Available:\n- %1").arg(modelList.map(id => `${models[id].name} (\`${id}\`)`).join("\n- ")), root.interfaceRole);
return;
}
modelId = match;
}
if (setPersistentState) Persistent.states.ai.model = modelId;
Souveraine.selectAgent(modelId);
root.currentModel = models[modelId];
if (feedback) root.addMessage(Translation.tr("Agent set to %1").arg(models[modelId].name), root.interfaceRole);
}
// Souveraine-owned settings: advice instead of local state
function setTool(tool) {
root.addMessage(Translation.tr("Tools are Souveraine sensors, configured per-agent — not switchable from the sidebar."), root.interfaceRole);
return false;
}
function getTemperature() { return root.temperature; }
function setTemperature(value) {
root.addMessage(Translation.tr("Temperature is set in the agent's llm_config in Souveraine."), root.interfaceRole);
}
function printTemperature() {
root.addMessage(Translation.tr("Temperature is owned by the agent's llm_config in Souveraine."), root.interfaceRole);
}
function setApiKey(key) {
root.addMessage(Translation.tr("Keys live in souveraine.toml — set them with:\n```bash\nsouveraine auth set\n```"), root.interfaceRole);
}
function printApiKey() {
root.addMessage(Translation.tr("Keys are owned by Souveraine (souveraine.toml / `souveraine auth set`), never exposed here."), root.interfaceRole);
}
function printPrompt() {
root.addMessage(Translation.tr("The system prompt is composed by Souveraine (constitution + memory blocks + sensorium). Inspect it with `souveraine agent show`."), root.interfaceRole);
}
function loadPrompt(filePath) {
root.addMessage(Translation.tr("Prompts are owned by the agent's memory in Souveraine — edit memfs instead of loading prompt files."), root.interfaceRole);
}
function attachFile(filePath) {
root.addMessage(Translation.tr("File attachments aren't wired to Souveraine yet."), root.interfaceRole);
}
function removePendingFile(file) {
root.pendingFiles = root.pendingFiles.filter(f => f !== file);
}
function regenerate(messageIndex) {
root.addMessage(Translation.tr("Regenerate isn't supported — Souveraine conversations are forward-only."), root.interfaceRole);
}
// Souveraine executes its own sensors server-side; nothing to approve.
function rejectCommand(message) {}
function approveCommand(message) {}
function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
return aiMessageComponent.createObject(root, {
"role": "user",
"content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
"functionName": name,
"functionResponse": output,
"thinking": false,
"done": true,
});
}
// Local chat snapshots (ii plumbing, unchanged)
Process {
id: getSavedChats
running: true
command: ["ls", "-1", Directories.aiChats]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
root.savedChats = text.split("\n")
.filter(fileName => fileName.endsWith(".json"))
.map(fileName => `${Directories.aiChats}/${fileName}`)
}
}
}
function chatToJson() {
return root.messageIDs.map(id => {
const message = root.messageByID[id]
return ({
"role": message.role,
"rawContent": message.rawContent,
"model": message.model,
"thinking": false,
"done": true,
})
})
}
FileView {
id: chatSaveFile
property string chatName: ""
path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : ""
blockLoading: true
}
FileView {
id: chatWriter
}
function saveChat(chatName) {
const filePath = `${Directories.aiChats}/${chatName.trim()}.json`
chatWriter.path = filePath
chatWriter.setText(JSON.stringify(root.chatToJson()))
getSavedChats.running = true;
}
function loadChat(chatName) {
try {
chatSaveFile.chatName = chatName.trim()
chatSaveFile.reload()
const saveData = JSON.parse(chatSaveFile.text())
root.clearMessages()
root.messageIDs = saveData.map((_, i) => i)
for (let i = 0; i < saveData.length; i++) {
const message = saveData[i];
root.messageByID[i] = root.aiMessageComponent.createObject(root, {
"role": message.role,
"rawContent": message.rawContent,
"content": message.rawContent,
"model": message.model,
"thinking": false,
"done": true,
});
}
root.addMessage(Translation.tr("Loaded a local snapshot. Note: this restores the transcript view only — the live Souveraine conversation starts fresh on the next message."), root.interfaceRole);
} catch (e) {
console.log("[Souveraine] Could not load chat: ", e);
} finally {
getSavedChats.running = true;
}
}
}

View file

@ -0,0 +1,317 @@
pragma Singleton
pragma ComponentBehavior: Bound
import qs.modules.common.functions as CF
import qs.modules.common
import Quickshell
import Quickshell.Io
import Quickshell.Wayland
import QtQuick
/**
* Souveraine the substrate singleton for every shell module.
*
* This is the one connection to the Souveraine server. The chat sidebar,
* presence widget, cockpit pane, agent manager and settings module all hang
* off this service; none of them open their own transport. Ai.qml is the
* ii-compat adapter over this for the existing sidebar UI.
*
* Responsibilities:
* - agent inventory (GET /v1/agents)
* - conversation lifecycle (create; resume/fork verbs to come)
* - the SSE turn stream raw events re-emitted via streamEvent(var)
* - the backchannel: cancelTurn() and interject(text)
* - the desktop sensorium: every send carries ambient context (active
* window, open apps, cursor position) so she perceives the room she is
* being spoken to in. Extension point for device sensors (SouveraineOS).
*/
Singleton {
id: root
property string serverBase: Config.options?.ai?.souveraineUrl ?? "http://127.0.0.1:8484"
property bool serverUp: false
// Ambient perception on by default; ai.ambient=false in ii config disables.
property bool ambientEnabled: Config.options?.ai?.ambient ?? true
// Start `souveraine server` ourselves when it isn't running. The surface
// is the OS frontend opening it means summoning her, not staring at a
// connection error. ai.souveraineAutostart=false disables; a manual
// start affordance can call startServer() directly.
property bool autostartEnabled: Config.options?.ai?.souveraineAutostart ?? true
property string serverBin: Config.options?.ai?.souveraineBin ?? "souveraine"
property bool _autostartTried: false
// id -> { name, description }
property var agents: ({})
property var agentList: Object.keys(agents)
property string currentAgentId: ""
property string conversationId: ""
property bool turnActive: false
/* Raw wire events (message_type-tagged objects from the SSE stream). */
signal streamEvent(var event)
/* Stream closed (process exit). exitCode 0 = clean. */
signal streamClosed(int exitCode)
signal agentsRefreshed()
signal serverUnreachable()
// Agent inventory
Process {
id: getAgents
running: true
command: ["curl", "-sf", "--max-time", "3", `${root.serverBase}/v1/agents`]
stdout: StdioCollector {
onStreamFinished: {
if (text.length === 0) return;
try {
const list = JSON.parse(text);
const map = {};
list.forEach(a => { map[a.id] = { "name": a.name, "description": a.description ?? "" }; });
root.agents = map;
root.agentList = Object.keys(map);
root.serverUp = true;
if (!root.agents[root.currentAgentId] && root.agentList.length > 0) {
root.currentAgentId = root.agentList[0];
}
root.agentsRefreshed();
} catch (e) {
console.log("[Souveraine] Could not parse agent list:", e);
}
}
}
onExited: (exitCode) => {
if (exitCode !== 0) {
root.serverUp = false;
if (root.autostartEnabled && !root._autostartTried) {
root.startServer();
} else {
root.serverUnreachable();
}
}
}
}
function refreshAgents() {
getAgents.running = true;
}
// Server autostart
// systemd user unit first (survives shell restarts, journald logging);
// bare nohup fallback for systems without it. One attempt per shell
// session a broken install shouldn't spawn-loop.
Process {
id: serverStarter
command: ["bash", "-c",
`if command -v systemctl >/dev/null && systemctl --user list-unit-files souveraine.service &>/dev/null; then
systemctl --user start souveraine.service
else
nohup ${root.serverBin} server >/dev/null 2>&1 &
fi`]
onExited: {
serverRetryTimer.start();
}
}
Timer {
id: serverRetryTimer
interval: 2500
repeat: false
onTriggered: root.refreshAgents()
}
function startServer() {
if (root._autostartTried) return;
root._autostartTried = true;
console.log("[Souveraine] server not reachable — starting it");
serverStarter.running = true;
}
function selectAgent(agentId) {
if (!root.agents[agentId]) return false;
root.currentAgentId = agentId;
root.conversationId = ""; // new agent, new conversation
return true;
}
function newConversation() {
root.conversationId = "";
}
// Ambient sensorium
// What the desktop feels like at the moment of speaking. Cheap,
// synchronous reads here; the cursor needs a hyprctl round-trip and is
// collected in the send chain. Device sensors (SouveraineOS positional
// data) extend collectAmbient().
property string _cursorPos: ""
function collectAmbient() {
if (!root.ambientEnabled) return "";
const lines = [];
const active = ToplevelManager.activeToplevel;
if (active) {
lines.push(`active window: ${active.appId ?? "?"} "${active.title ?? ""}"`);
}
const tops = ToplevelManager.toplevels?.values ?? [];
if (tops.length > 0) {
const apps = tops.map(t => t.appId).filter(Boolean);
const counts = {};
apps.forEach(a => counts[a] = (counts[a] ?? 0) + 1);
const summary = Object.entries(counts)
.map(([app, n]) => n > 1 ? `${app} (${n})` : app)
.join(", ");
lines.push(`open: ${summary}`);
}
if (root._cursorPos.length > 0) {
lines.push(`cursor: ${root._cursorPos}`);
}
return lines.join("\n");
}
// Compositor-specific cursor read. hyprctl on Hyprland, kdotool on KDE;
// anything else just skips the cursor line ambient degrades gracefully,
// it never blocks the send.
Process {
id: cursorProc
command: ["bash", "-c",
`if command -v hyprctl >/dev/null; then hyprctl cursorpos;
elif command -v kdotool >/dev/null; then kdotool getmouselocation 2>/dev/null;
fi`]
stdout: StdioCollector {
onStreamFinished: {
root._cursorPos = text.trim();
}
}
onExited: {
root._ensureConversationThenRequest();
}
}
// Send chain: cursor conversation stream
property string _queuedText: ""
/* Send a user message with ambient context. Returns false if the
server is down or no agent is selected. */
function send(text) {
if (!root.serverUp || root.currentAgentId.length === 0) return false;
if (text.length === 0) return false;
root._queuedText = text;
if (root.ambientEnabled) {
cursorProc.running = true; // chain continues in onExited
} else {
root._ensureConversationThenRequest();
}
return true;
}
Process {
id: createConversation
stdout: StdioCollector {
onStreamFinished: {
try {
const conv = JSON.parse(text);
root.conversationId = conv.id;
root._makeRequest();
} catch (e) {
console.log("[Souveraine] conversation create failed:", text);
root.streamClosed(1);
}
}
}
}
function _ensureConversationThenRequest() {
if (root.conversationId.length > 0) {
root._makeRequest();
return;
}
createConversation.command = [
"curl", "-sf", "-X", "POST",
`${root.serverBase}/v1/conversations`,
"-H", "Content-Type: application/json",
"--data", JSON.stringify({ "agent_id": root.currentAgentId })
];
createConversation.running = true;
}
property string requestScriptFilePath: "/tmp/quickshell/ai/souveraine-request.sh"
FileView {
id: requesterScriptFile
}
function _tokenReadLine(agentId) {
// Bearer token read at request time so rotation works.
return `TOKEN=$(cat "$HOME/.souveraine/server/agents/${agentId}/api_token" 2>/dev/null)\n`;
}
function _makeRequest() {
const data = {
"messages": [{ "role": "user", "content": root._queuedText }],
"stream": true
};
const ambient = root.collectAmbient();
if (ambient.length > 0) data["ambient"] = ambient;
root._queuedText = "";
const scriptContent = "#!/usr/bin/env bash\n"
+ root._tokenReadLine(root.currentAgentId)
+ `curl --no-buffer -sS -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/messages"`
+ ` -H 'Content-Type: application/json'`
+ ` -H "Authorization: Bearer $TOKEN"`
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
+ "\n";
const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath);
requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath);
requesterScriptFile.setText(scriptContent);
requester.command = ["bash", shellScriptPath];
root.turnActive = true;
requester.running = true;
}
Process {
id: requester
stdout: SplitParser {
onRead: data => {
if (data.length === 0 || !data.startsWith("data:")) return;
let event;
try {
event = JSON.parse(data.slice(5).trim());
} catch (e) {
console.log("[Souveraine] Unparseable SSE line:", data);
return;
}
root.streamEvent(event);
}
}
onExited: (exitCode, exitStatus) => {
root.turnActive = false;
root.streamClosed(exitCode);
}
}
// Backchannel
Process {
id: backchannelProc
property string script: ""
command: ["bash", "-c", script]
}
function cancelTurn() {
if (root.conversationId.length === 0) return;
backchannelProc.script = root._tokenReadLine(root.currentAgentId)
+ `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/cancel"`
+ ` -H "Authorization: Bearer $TOKEN"`;
backchannelProc.running = true;
}
function interject(text) {
if (root.conversationId.length === 0 || text.length === 0) return;
const body = JSON.stringify({ "text": text });
backchannelProc.script = root._tokenReadLine(root.currentAgentId)
+ `curl -sf -X POST "${root.serverBase}/v1/conversations/${root.conversationId}/interject"`
+ ` -H 'Content-Type: application/json'`
+ ` -H "Authorization: Bearer $TOKEN"`
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(body)}'`;
backchannelProc.running = true;
}
}

View file

@ -0,0 +1,205 @@
pragma Singleton
import qs.modules.common
import QtQuick
import Quickshell
import Quickshell.Wayland
Singleton {
id: root
function isPinned(appId) {
return Config.options.dock.pinnedApps.indexOf(appId) !== -1;
}
function togglePin(appId) {
if (root.isPinned(appId)) {
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appId)
} else {
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([appId])
}
}
// --- Fan-out stacks ------------------------------------------------
// Backing store: Config.options.dock.stacks, a list<string> where each
// entry is "stackId|appId,appId,appId". stackId is a display label and
// the key; members are appIds. Same string-list-in-JsonAdapter shape as
// pinnedApps (nested-object arrays don't survive the adapter).
function parseStack(entry) {
const bar = entry.indexOf("|");
if (bar === -1) return { id: entry, members: [] };
const id = entry.slice(0, bar);
const rest = entry.slice(bar + 1).trim();
const members = rest.length ? rest.split(",").map(s => s.trim()).filter(s => s.length) : [];
return { id: id, members: members };
}
function stacksList() {
return (Config.options?.dock.stacks ?? []).map(root.parseStack);
}
// appId -> the stackId that contains it, or "" if none.
function stackContaining(appId) {
const low = appId.toLowerCase();
for (const s of root.stacksList()) {
if (s.members.some(m => m.toLowerCase() === low)) return s.id;
}
return "";
}
function encodeStack(id, members) {
return id + "|" + members.join(",");
}
// Rewrite the whole stacks list from a parsed [{id, members}] array,
// dropping any that end up empty.
function writeStacks(parsed) {
Config.options.dock.stacks = parsed
.filter(s => s.members.length > 0)
.map(s => root.encodeStack(s.id, s.members));
}
function addToStack(stackId, appId) {
const parsed = root.stacksList();
const existing = parsed.find(s => s.id === stackId);
if (existing) {
if (!existing.members.some(m => m.toLowerCase() === appId.toLowerCase()))
existing.members = existing.members.concat([appId]);
} else {
parsed.push({ id: stackId, members: [appId] });
}
root.writeStacks(parsed);
}
function removeFromStack(stackId, appId) {
const parsed = root.stacksList();
const existing = parsed.find(s => s.id === stackId);
if (!existing) return;
existing.members = existing.members.filter(m => m.toLowerCase() !== appId.toLowerCase());
root.writeStacks(parsed);
}
// --- Drag interactions (Tier 1) ------------------------------------
// Auto-name a fresh stack when two loose icons are combined. "Stack N"
// where N is one past the current count; rename waits for the menu.
function nextStackName() {
return "Stack " + ((Config.options?.dock.stacks?.length ?? 0) + 1);
}
// Drag `draggedAppId` onto `targetAppId` -> combine. If target is
// already a stack (stackId non-empty), add into it; else make a new
// stack containing target then dragged (target stays on top = first).
function combineIntoStack(targetAppId, draggedAppId, targetStackId) {
if (!draggedAppId || draggedAppId.toLowerCase() === targetAppId.toLowerCase()) return;
// If the dragged app is currently in some stack, pull it out first.
const from = root.stackContaining(draggedAppId);
if (from) root.removeFromStack(from, draggedAppId);
if (targetStackId) {
root.addToStack(targetStackId, draggedAppId);
} else {
const id = root.nextStackName();
const parsed = root.stacksList();
parsed.push({ id: id, members: [targetAppId, draggedAppId] });
// targetAppId was a loose pinned app; drop it from standalone
// pinned so it lives only in the stack now.
root.writeStacks(parsed);
}
}
// Reorder a member within its stack by delta (-1 up/left, +1 down/right).
// Member 0 is the collapsed/top icon.
function reorderStackMember(stackId, appId, delta) {
const parsed = root.stacksList();
const s = parsed.find(x => x.id === stackId);
if (!s) return;
const i = s.members.findIndex(m => m.toLowerCase() === appId.toLowerCase());
const j = i + delta;
if (i < 0 || j < 0 || j >= s.members.length) return;
const m = s.members.slice();
[m[i], m[j]] = [m[j], m[i]];
s.members = m;
root.writeStacks(parsed);
}
// Pull a member out of its stack back to a standalone pinned app.
function unstackMember(stackId, appId) {
root.removeFromStack(stackId, appId);
if (!root.isPinned(appId)) root.togglePin(appId);
}
property list<var> apps: {
var map = new Map();
// Fan-out stacks come first, in their configured order. Their
// member appIds are suppressed as standalone pinned entries below
// so an app lives in one place: its stack.
const stacks = root.stacksList();
const stackedMembers = new Set();
for (const s of stacks) {
for (const m of s.members) stackedMembers.add(m.toLowerCase());
map.set("STACK:" + s.id, {
pinned: true, toplevels: [], isStack: true, members: s.members
});
}
// Pinned apps (skip any already living in a stack)
const pinnedApps = Config.options?.dock.pinnedApps ?? [];
for (const appId of pinnedApps) {
if (stackedMembers.has(appId.toLowerCase())) continue;
if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({
pinned: true,
toplevels: []
}));
}
// Separator
if (map.size > 0) {
map.set("SEPARATOR", { pinned: false, toplevels: [] });
}
// Ignored apps
const ignoredRegexStrings = Config.options?.dock.ignoredAppRegexes ?? [];
const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i"));
// Open windows
for (const toplevel of ToplevelManager.toplevels.values) {
if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue;
// A running app that belongs to a stack keeps its running dot on
// the stack, not as a separate icon.
if (stackedMembers.has(toplevel.appId.toLowerCase())) continue;
if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({
pinned: false,
toplevels: []
}));
map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel);
}
var values = [];
for (const [key, value] of map) {
values.push(appEntryComp.createObject(null, {
appId: value.isStack ? key.slice(6) : key,
toplevels: value.toplevels,
pinned: value.pinned,
isStack: value.isStack ?? false,
members: value.members ?? []
}));
}
return values;
}
component TaskbarAppEntry: QtObject {
id: wrapper
required property string appId
required property list<var> toplevels
required property bool pinned
property bool isStack: false
property list<var> members: []
}
Component {
id: appEntryComp
TaskbarAppEntry {}
}
}