Skip to content
This repository was archived by the owner on May 14, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
907 changes: 907 additions & 0 deletions Cargo.lock

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,17 @@ edition = "2024"

[dependencies]
anyhow = "1"
async-channel = "2"
bytes = "1.11.1"
clap = { version = "4.5", features = ["derive", "env"] }
futures-util = "0.3"
gtk4 = "0.9"
indicatif = "0.17"
libadwaita = { version = "0.7", features = ["v1_4"] }
pulldown-cmark = "0.11"
reqwest = { version = "0.12", features = ["json", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
text-splitter = { version = "0.14", features = ["markdown"] }
tokio = { version = "1", features = ["full"] }
zbus = "5"
9 changes: 9 additions & 0 deletions data/com.canonical.UbuntuDesktopHelp.desktop.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[Desktop Entry]
Type=Application
Name=Ubuntu Desktop Help
Comment=Ask questions about using Ubuntu
Exec=@BINARY@ --copilot gui
Icon=help-browser
Terminal=false
Categories=GNOME;GTK;System;
StartupNotify=true
5 changes: 5 additions & 0 deletions data/com.canonical.UbuntuDesktopHelp.search-provider.ini
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
[Shell Search Provider]
DesktopId=com.canonical.UbuntuDesktopHelp.desktop
BusName=com.canonical.UbuntuDesktopHelp
ObjectPath=/com/canonical/UbuntuDesktopHelp/SearchProvider
Version=2
3 changes: 3 additions & 0 deletions data/com.canonical.UbuntuDesktopHelp.service.in
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
[D-BUS Service]
Name=com.canonical.UbuntuDesktopHelp
Exec=@BINARY@ --copilot dbus
86 changes: 86 additions & 0 deletions search-setup.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
#!/usr/bin/env bash
# Installs (or uninstalls) the GNOME Shell search provider for the current user.
#
# Drops three files under ~/.local with the path to the locally-built binary
# substituted in. Run after `cargo build --release`.
#
# Requires a GNOME Shell session restart (logout / login on Wayland) for
# GNOME to pick up changes to the search-provider .ini.
#
# Usage:
# ./search-setup.sh install
# ./search-setup.sh --uninstall remove the three files and kill any running daemon
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BINARY="${REPO_ROOT}/target/release/ubuntu-desktop-help"

DATA_HOME="${XDG_DATA_HOME:-${HOME}/.local/share}"
SP_FILE="${DATA_HOME}/gnome-shell/search-providers/com.canonical.UbuntuDesktopHelp.search-provider.ini"
DBUS_FILE="${DATA_HOME}/dbus-1/services/com.canonical.UbuntuDesktopHelp.service"
DESKTOP_FILE="${DATA_HOME}/applications/com.canonical.UbuntuDesktopHelp.desktop"

uninstall() {
# Ask any running daemon to drop its bus name so the next overview search
# re-activates the binary we're about to remove. Best-effort.
pkill -f "ubuntu-desktop-help.*dbus" 2>/dev/null || true

rm -f "${SP_FILE}" "${DBUS_FILE}" "${DESKTOP_FILE}"

if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$(dirname "${DESKTOP_FILE}")" >/dev/null 2>&1 || true
fi

cat <<EOF
Removed:
${SP_FILE}
${DBUS_FILE}
${DESKTOP_FILE}

Log out and back in to make GNOME Shell forget the search provider.
EOF
}

install() {
if [[ ! -x "${BINARY}" ]]; then
echo "error: ${BINARY} not found — run \`cargo build --release\` first" >&2
exit 1
fi

mkdir -p "$(dirname "${SP_FILE}")" "$(dirname "${DBUS_FILE}")" "$(dirname "${DESKTOP_FILE}")"

# Kill any previously-installed daemon so the new .service file is honoured.
pkill -f "ubuntu-desktop-help.*dbus" 2>/dev/null || true

# Substitute @BINARY@ with the absolute path to the built binary.
sed "s|@BINARY@|${BINARY}|g" \
"${REPO_ROOT}/data/com.canonical.UbuntuDesktopHelp.service.in" \
> "${DBUS_FILE}"

sed "s|@BINARY@|${BINARY}|g" \
"${REPO_ROOT}/data/com.canonical.UbuntuDesktopHelp.desktop.in" \
> "${DESKTOP_FILE}"

# The .ini file has no substitutions; copy as-is.
cp "${REPO_ROOT}/data/com.canonical.UbuntuDesktopHelp.search-provider.ini" "${SP_FILE}"

if command -v update-desktop-database >/dev/null 2>&1; then
update-desktop-database "$(dirname "${DESKTOP_FILE}")" >/dev/null 2>&1 || true
fi

cat <<EOF
Installed:
${DBUS_FILE}
${DESKTOP_FILE}
${SP_FILE}

Log out and back in so GNOME Shell picks up the new search provider, then try:
?? how do I change my wallpaper
EOF
}

case "${1:-}" in
--uninstall|-u|uninstall) uninstall ;;
""|--install|install) install ;;
*) echo "usage: $0 [--uninstall]" >&2; exit 2 ;;
esac
206 changes: 206 additions & 0 deletions src/gui.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,206 @@
use anyhow::Result;
use gtk4::glib;
use gtk4::prelude::*;
use libadwaita as adw;
use libadwaita::prelude::*;

use crate::llm::{CopilotClient, LlmClient, Message, OllamaClient};
use crate::{DEFAULT_OLLAMA_URL, SYSTEM_PROMPT};

const APP_ID: &str = "com.canonical.UbuntuDesktopHelp";

// Messages sent from the LLM worker thread to the GTK main loop.
enum StreamMsg {
Token(String),
Error(String),
Done,
}

// Entry point for `ubuntu-desktop-help gui <query>`. Starts the GTK main loop
// and presents a single window that streams the answer for `query`.
pub fn run(query: String, use_copilot: bool, model: String) -> Result<()> {
let app = adw::Application::builder().application_id(APP_ID).build();

// connect_activate is called when the application is told to present itself.
// We pass an empty argv to .run_with_args because clap has already consumed
// the real argv and GTK would otherwise re-parse it.
app.connect_activate(move |app| {
build_window(app, query.clone(), use_copilot, model.clone());
});

let empty: [&str; 0] = [];
app.run_with_args(&empty);
Ok(())
}

fn build_window(app: &adw::Application, query: String, use_copilot: bool, model: String) {
let window = adw::ApplicationWindow::builder()
.application(app)
.title("Ubuntu Desktop Help")
.default_width(640)
.default_height(520)
.build();

// ToolbarView gives us a standard GNOME header bar above the content area.
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);

// Vertical box: the user's question on top, the streamed answer below.
let vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(8)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();

let header_text = if query.is_empty() {
"Type your question in the GNOME overview after <tt>??</tt>".to_string()
} else {
format!("<b>{}</b>", glib::markup_escape_text(&query))
};
let question_label = gtk4::Label::builder()
.label(&header_text)
.use_markup(true)
.xalign(0.0)
.wrap(true)
.wrap_mode(gtk4::pango::WrapMode::WordChar)
.build();
vbox.append(&question_label);

// Spinner shown until the first token arrives. Hidden when there's nothing to ask.
let spinner = gtk4::Spinner::builder().spinning(true).build();
let spinner_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
spinner_row.append(&spinner);
let thinking = gtk4::Label::new(Some("Thinking…"));
spinner_row.append(&thinking);
if query.is_empty() {
spinner_row.set_visible(false);
}
vbox.append(&spinner_row);

// TextView holds the streamed answer. It starts empty and is filled
// token-by-token as the model responds.
let text_view = gtk4::TextView::builder()
.editable(false)
.cursor_visible(false)
.wrap_mode(gtk4::WrapMode::WordChar)
.top_margin(8)
.bottom_margin(8)
.left_margin(4)
.right_margin(4)
.build();
let buffer = text_view.buffer();

let scroll = gtk4::ScrolledWindow::builder()
.vexpand(true)
.hscrollbar_policy(gtk4::PolicyType::Never)
.child(&text_view)
.build();
vbox.append(&scroll);

toolbar.set_content(Some(&vbox));
window.set_content(Some(&toolbar));
window.present();

// Channel from the LLM worker (tokio thread) to the GTK main loop. Only
// start a worker if there's actually something to ask; an empty-query
// launch (from the app grid) just shows the placeholder above.
let (tx, rx) = async_channel::unbounded::<StreamMsg>();
if !query.is_empty() {
spawn_worker(query, use_copilot, model, tx);
} else {
let _ = tx.send_blocking(StreamMsg::Done);
}

// Drain the channel on the GTK main loop and update the buffer in place.
glib::spawn_future_local(async move {
let mut got_first_token = false;
while let Ok(msg) = rx.recv().await {
match msg {
StreamMsg::Token(t) => {
if !got_first_token {
spinner_row.set_visible(false);
got_first_token = true;
}
let mut end = buffer.end_iter();
buffer.insert(&mut end, &t);
}
StreamMsg::Error(e) => {
spinner_row.set_visible(false);
let mut end = buffer.end_iter();
buffer.insert(&mut end, &format!("Error: {e}"));
}
StreamMsg::Done => break,
}
}
});
}

// Spawns a thread that runs a single-threaded tokio runtime, executes one
// chat call, and forwards tokens to the GTK side via `tx`. We use a fresh
// thread because GTK and tokio each want to own the main thread.
fn spawn_worker(
query: String,
use_copilot: bool,
model: String,
tx: async_channel::Sender<StreamMsg>,
) {
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
let _ = tx.send_blocking(StreamMsg::Error(format!("runtime: {e}")));
let _ = tx.send_blocking(StreamMsg::Done);
return;
}
};

rt.block_on(async move {
let client = if use_copilot {
match CopilotClient::create().await {
Ok(c) => LlmClient::Copilot(c),
Err(e) => {
let _ = tx.send(StreamMsg::Error(format!("auth: {e}"))).await;
let _ = tx.send(StreamMsg::Done).await;
return;
}
}
} else {
LlmClient::Ollama(OllamaClient::new(DEFAULT_OLLAMA_URL.to_string(), model))
};

let messages = vec![
Message {
role: "system".into(),
content: SYSTEM_PROMPT.into(),
},
Message {
role: "user".into(),
content: query,
},
];

let tx_token = tx.clone();
let on_first_token = || {};
let on_token = |t: &str| {
// send_blocking on an unbounded channel only blocks if the
// receiver was dropped, in which case we just give up.
let _ = tx_token.send_blocking(StreamMsg::Token(t.to_string()));
};
let result = client.chat(&messages, on_first_token, on_token).await;
if let Err(e) = result {
let _ = tx.send(StreamMsg::Error(e.to_string())).await;
}
let _ = tx.send(StreamMsg::Done).await;
});
});
}
Loading