Compare commits

2 Commits
4 changed files with 171 additions and 53 deletions
+2 -2
View File
@@ -115,7 +115,7 @@ Whether the open card takes the song cover's colours instead (`appearance.colors
Two small one-icon pills sit left of the island and two right of it, on every monitor; they move outward while the island opens. A click morphs a pill into its card. Only one card is open across the shell: opening the island closes a card, and opening a card closes the island. Esc, the cross or a click elsewhere closes it. Two small one-icon pills sit left of the island and two right of it, on every monitor; they move outward while the island opens. A click morphs a pill into its card. Only one card is open across the shell: opening the island closes a card, and opening a card closes the island. Esc, the cross or a click elsewhere closes it.
- **AI** (sparkle): a chat with a local Ollama model, `gemma4:e4b` at `http://localhost:11434` by default; the model chip in its header lists the installed models and switches at once. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing leaves the machine. - **AI** (sparkle): a chat with an Ollama model on your own servers, `gemma4:e4b` at `http://localhost:11434` by default. `pills.ai.servers` takes several, comma-separated, in order: the model chip in the header lists every server's models (each once, with its server when there are several) and switches at once, and a question goes to the first server that has the chosen model. Answers stream in through curl. When Ollama doesn't answer, the shell starts the OpenRC service with `doas rc-service ollama start` (`pills.ai.start_service`). Stop ends an answer and New chat starts over. Nothing goes to a cloud service, only to the servers listed.
- **System** (pulse): CPU, the integrated GPU's clock, the external GPU's load (with its video memory and the programs on it, formerly in the control centre), memory, swap, network and disk read/write as graphs over the last minute, plus the hottest sensors with icons, in cool/warm/hot colours (`pills.monitor.warm|hot`). Everything is read from `/proc` and `/sys`, so there is no process list. Processes opens `ghostty --class=amane.monitor -e btm` on the special workspace `monitor`; a second press shows or hides it. - **System** (pulse): CPU, the integrated GPU's clock, the external GPU's load (with its video memory and the programs on it, formerly in the control centre), memory, swap, network and disk read/write as graphs over the last minute, plus the hottest sensors with icons, in cool/warm/hot colours (`pills.monitor.warm|hot`). Everything is read from `/proc` and `/sys`, so there is no process list. Processes opens `ghostty --class=amane.monitor -e btm` on the special workspace `monitor`; a second press shows or hides it.
- **Home** (house): `~/.face` as the avatar (a person glyph until the file exists), a greeting, the date, a big clock, and Munich's weather now and for four days from open-meteo (no key), over a photo of the current weather (`~/.config/amane/weather/<kind>-<day|night>.jpg`, downloaded once from Wikimedia Commons, credits in `weather/CREDITS.md`; swap any file, `pills.home.photo_opacity` tones it down). A big month grid marks events (dots in their calendars' colours), birthdays from your iCloud contacts (a cake) and the public holidays of `pills.calendar.holidays` (BY by default); click a day for its list. Events can be created, edited and deleted there (a series: title, place and calendar only). For iCloud, set `pills.calendar.account` to your Apple ID and store an [app-specific password](https://account.apple.com) in the keyring: `secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>` (gnome-keyring or another Secret Service has to run). The password never goes into `config.toml`. Other CalDAV/CardDAV servers work through `pills.calendar.server` and `contacts_server`. - **Home** (house): `~/.face` as the avatar (a person glyph until the file exists), a greeting, the date, a big clock, and Munich's weather now and for four days from open-meteo (no key), over a photo of the current weather (`~/.config/amane/weather/<kind>-<day|night>.jpg`, downloaded once from Wikimedia Commons, credits in `weather/CREDITS.md`; swap any file, `pills.home.photo_opacity` tones it down). A big month grid marks events (dots in their calendars' colours), birthdays from your iCloud contacts (a cake) and the public holidays of `pills.calendar.holidays` (BY by default); click a day for its list. Events can be created, edited and deleted there (a series: title, place and calendar only). For iCloud, set `pills.calendar.account` to your Apple ID and store an [app-specific password](https://account.apple.com) in the keyring: `secret-tool store --label='Magic Island calendar' service amane-calendar account <Apple ID>` (gnome-keyring or another Secret Service has to run). The password never goes into `config.toml`. Other CalDAV/CardDAV servers work through `pills.calendar.server` and `contacts_server`.
- **Notifications** (bell, with the unread count): Amane is the notification daemon (`org.freedesktop.Notifications`), so dunst must not run; it is commented out in `~/.config/hypr/autocmds.lua` (the old file is beside it as `autocmds.lua.before-amane-notifications`). Popups appear at the top right of the focused monitor with the app's icon or image, summary, body (simple markup) and action buttons. They time out by urgency (critical ones stay) and wait while the pointer is over them. A click runs the default action. The card lists the history, newest first; each entry's cross deletes it, and Clear all deletes everything. Mute app keeps an app's notifications in the history without popups; Block app drops them (`notifications.apps.muted|blocked`, undone from chips at the top of the card). Do not disturb turns all popups off. - **Notifications** (bell, with the unread count): Amane is the notification daemon (`org.freedesktop.Notifications`), so dunst must not run; it is commented out in `~/.config/hypr/autocmds.lua` (the old file is beside it as `autocmds.lua.before-amane-notifications`). Popups appear at the top right of the focused monitor with the app's icon or image, summary, body (simple markup) and action buttons. They time out by urgency (critical ones stay) and wait while the pointer is over them. A click runs the default action. The card lists the history, newest first; each entry's cross deletes it, and Clear all deletes everything. Mute app keeps an app's notifications in the history without popups; Block app drops them (`notifications.apps.muted|blocked`, undone from chips at the top of the card). Do not disturb turns all popups off.
@@ -128,7 +128,7 @@ Left out on purpose: phone stats (iOS shares nothing Linux can read without extr
Every setting lives in `~/.config/amane/config.toml`: sizes, timings, text sizes, colours, poll intervals, equalizer presets and bands, the hotspot profile, the extra Sound output. The shell writes the file with every setting and its explanation the first time it starts (moving `cover_colors`, `transition_ms`, `hotspot` and `extra_output` over from an old `shell.conf`, which it no longer reads). Saving the file applies it at once; a line left out takes its default, and a line the shell can't use is listed at the top of the config window. The font and the cava bar count take effect on restart. Every setting lives in `~/.config/amane/config.toml`: sizes, timings, text sizes, colours, poll intervals, equalizer presets and bands, the hotspot profile, the extra Sound output. The shell writes the file with every setting and its explanation the first time it starts (moving `cover_colors`, `transition_ms`, `hotspot` and `extra_output` over from an old `shell.conf`, which it no longer reads). Saving the file applies it at once; a line left out takes its default, and a line the shell can't use is listed at the top of the config window. The font and the cava bar count take effect on restart.
The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default. The config window edits the same file: Settings at the foot of the control centre, or `amane ipc call config toggle`. It is a normal window with a page per part of the shell, a page per Hyprland category, and a search over everything; typing with no field selected (as right after opening it) goes into the search. Numbers with a range get a slider and a field, switches toggle, colours show a swatch; a typed value applies on Enter. Reset brings back the default.
### Glass ### Glass
+117 -38
View File
@@ -14,10 +14,11 @@ use crate::parse::{self, Chunk};
use crate::{pods, theme}; use crate::{pods, theme};
/* /*
* the AI card: a chat with a local model through ollama's http api (curl, streamed line * the AI card: a chat with a model on your own ollama servers (pills.ai.servers) through
* by line), never a cloud service. ollama isn't kept running: the first question starts * their http api (curl, streamed line by line), never a cloud service. a question goes to
* its openrc service when it doesn't answer (doas, the same fixed rc-service rule the * the first server that has the chosen model. a local ollama isn't kept running: it's
* control centre uses) * started (doas, the same fixed rc-service rule the control centre uses) when it doesn't
* answer and no other server has the model
*/ */
#[derive(Clone, Copy, PartialEq)] #[derive(Clone, Copy, PartialEq)]
pub enum Who { pub enum Who {
@@ -36,8 +37,8 @@ pub struct Ai {
typed: String, typed: String,
// bumped by Stop and New chat, so an answer still streaming stops writing // bumped by Stop and New chat, so an answer still streaming stops writing
round: u32, round: u32,
// the models ollama has, while the model list is open (empty while it loads) // (server, model) for every model the servers have, while the model list is open (empty while it loads)
models: Option<Vec<String>>, models: Option<Vec<(String, String)>>,
} }
impl Service for Ai { impl Service for Ai {
@@ -114,11 +115,15 @@ fn new_chat() {
ai.back = 0.0; ai.back = 0.0;
} }
// on the answering thread: start ollama if needed, then stream the reply into the chat // on the answering thread: find the model's server (starting a local ollama if needed), then stream the reply into the chat
fn answer(round: u32) -> Result<(), String> { fn answer(round: u32) -> Result<(), String> {
let server = settings::server().trim_end_matches('/').to_string(); let model = settings::model();
wake(&server, |words| note(round, words))?; let server = models(Some(&model), |words| note(round, words))?
.into_iter()
.find(|(_, name)| *name == model)
.map(|(server, _)| server)
.ok_or_else(|| format!("None of pills.ai.servers has {model}"))?;
let body = request(); let body = request();
@@ -170,18 +175,97 @@ fn answer(round: u32) -> Result<(), String> {
Ok(()) Ok(())
} }
// starts the local ollama service when it doesn't answer, telling `say` what's going on // pills.ai.servers, in order, without trailing slashes
fn servers() -> Vec<String> {
settings::servers().split(',').map(|server| server.trim().trim_end_matches('/').to_string()).filter(|server| !server.is_empty()).collect()
}
fn local(server: &str) -> bool {
server.contains("://localhost") || server.contains("://127.0.0.1")
}
// the models a server has; none when it doesn't answer
fn models_on(server: &str) -> Option<Vec<String>> {
let done = Command::new("curl").args(["-s", "--connect-timeout", "1", "-m", "5", &format!("{server}/api/tags")]).output().ok()?;
if !done.status.success() {
return None;
}
let tags = parse::json(&String::from_utf8_lossy(&done.stdout));
let names = tags.first().and_then(|tags| tags.get("models")).map(|models| match models {
parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(),
_ => Vec::new(),
});
Some(names.unwrap_or_default())
}
/*
* (server, model) for the servers' models, in the servers' order, each model once with the
* first server that has it; with `wanted`, only until a server has that model. a local
* server that doesn't answer is started (pills.ai.start_service) when the others don't have
* `wanted`, or for the whole list; `say` tells what's going on
*/
fn models(wanted: Option<&str>, say: impl Fn(&str)) -> Result<Vec<(String, String)>, String> {
let servers = servers();
if servers.is_empty() {
return Err(String::from("pills.ai.servers names no server"));
}
let mut started = false;
loop {
let mut found: Vec<(String, String)> = Vec::new();
let mut silent_local = None;
let mut answered = false;
for server in &servers {
let Some(names) = models_on(server) else {
if local(server) && silent_local.is_none() {
silent_local = Some(server.clone());
}
continue;
};
answered = true;
for name in names {
if !found.iter().any(|(_, known)| *known == name) {
found.push((server.clone(), name));
}
}
if wanted.is_some_and(|wanted| found.iter().any(|(_, name)| name == wanted)) {
return Ok(found);
}
}
if let Some(server) = silent_local.filter(|_| !started && settings::start_service()) {
wake(&server, &say)?;
started = true;
continue;
}
return match (answered, found.is_empty()) {
(false, _) => Err(format!("No Ollama server answers ({})", servers.join(", "))),
(true, true) => Err(String::from("The servers have no models (ollama pull <model>)")),
(true, false) => Ok(found),
};
}
}
// starts the local ollama service, telling `say` what's going on, and waits until it answers
fn wake(server: &str, say: impl Fn(&str)) -> Result<(), String> { fn wake(server: &str, say: impl Fn(&str)) -> Result<(), String> {
if answers(server) { if answers(server) {
return Ok(()); return Ok(());
} }
let local = server.contains("://localhost") || server.contains("://127.0.0.1");
if !(settings::start_service() && local) {
return Err(format!("Ollama doesn't answer at {server}"));
}
say("Starting ollama…"); say("Starting ollama…");
let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output(); let _ = Command::new("doas").args(["-n", "rc-service", "ollama", "start"]).output();
@@ -213,24 +297,7 @@ fn pick() {
drop(ai); drop(ai);
thread::spawn(|| { thread::spawn(|| {
let server = settings::server().trim_end_matches('/').to_string(); let names = models(None, |words| Ai::write().note = (!words.is_empty()).then(|| words.to_string()));
let say = |words: &str| Ai::write().note = (!words.is_empty()).then(|| words.to_string());
let names = wake(&server, say).and_then(|()| {
let done = Command::new("curl").args(["-s", "-m", "5", &format!("{server}/api/tags")]).output().map_err(|error| format!("curl: {error}"))?;
let tags = parse::json(&String::from_utf8_lossy(&done.stdout));
let names: Vec<String> = tags
.first()
.and_then(|tags| tags.get("models"))
.map(|models| match models {
parse::Value::List(models) => models.iter().filter_map(|model| model.get("name").and_then(parse::Value::text).map(str::to_string)).collect(),
_ => Vec::new(),
})
.unwrap_or_default();
if names.is_empty() { Err(String::from("Ollama has no models (ollama pull <model>)")) } else { Ok(names) }
});
let mut ai = Ai::write(); let mut ai = Ai::write();
@@ -366,12 +433,17 @@ pub fn card(width: f32, height: f32) -> Rectangle {
let chat: Box<dyn Widget> = if let Some(models) = &ai.models { let chat: Box<dyn Widget> = if let Some(models) = &ai.models {
let current = settings::model(); let current = settings::model();
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(if models.is_empty() { "Asking ollama for its models…" } else { "Chat with" }).size(text::small()).color(theme.dim))]; let several = servers().len() > 1;
let mut rows: Vec<Box<dyn Widget>> = vec![Box::new(Text::new(if models.is_empty() { "Asking the servers for their models…" } else { "Chat with" }).size(text::small()).color(theme.dim))];
for name in models { for (server, name) in models {
let chosen = *name == current; let chosen = *name == current;
let picked = name.clone(); let picked = name.clone();
// with more than one server, where the model is, without the scheme
let host = if several { server.split("://").last().unwrap_or(server) } else { "" };
let host_w = host.chars().count() as f32 * text::small() * crate::config::appearance::font::letter_width();
rows.push(Box::new( rows.push(Box::new(
Rectangle::new() Rectangle::new()
.width(width) .width(width)
@@ -382,13 +454,20 @@ pub fn card(width: f32, height: f32) -> Rectangle {
.align_child(Start, Center) .align_child(Start, Center)
.cursor(amane::Pointer) .cursor(amane::Pointer)
.on_click(move |_| choose(picked.clone())) .on_click(move |_| choose(picked.clone()))
.child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide()), .child(
Row::new(vec![
Box::new(Rectangle::new().width(width - 24.0 - host_w - 12.0).height(34.0).align_child(Start, Center).child(Text::new(name.as_str()).size(text::body()).color(theme.text).elide())) as Box<dyn Widget>,
Box::new(Text::new(host).size(text::small()).color(theme.dim)),
])
.gap(12.0)
.align(Center),
),
)); ));
} }
Box::new(Rectangle::new().width(width).height(chat_h).clip().child(Column::new(rows).gap(6.0))) Box::new(Rectangle::new().width(width).height(chat_h).clip().child(Column::new(rows).gap(6.0)))
} else if bubbles.is_empty() { } else if bubbles.is_empty() {
Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(format!("Ask {} anything; it runs on this computer", settings::model())).size(text::small()).color(theme.dim))) Box::new(Rectangle::new().width(width).height(chat_h).align_child(Center, Center).child(Text::new(if servers().iter().all(|server| local(server)) { format!("Ask {} anything; it runs on this computer", settings::model()) } else { format!("Ask {} anything; it runs on your own servers", settings::model()) }).size(text::small()).color(theme.dim)))
} else { } else {
// the column's bottom on the area's bottom, moved down by the scroll back; what's above is cut // the column's bottom on the area's bottom, moved down by the scroll back; what's above is cut
let back = ai.back.min((total - chat_h).max(0.0)); let back = ai.back.min((total - chat_h).max(0.0));
+1 -1
View File
@@ -513,7 +513,7 @@ settings! {
ai { ai {
width: f32 = 440.0, 280..=1200; "Width of the AI card" width: f32 = 440.0, 280..=1200; "Width of the AI card"
height: f32 = 520.0, 200..=1200; "Height of the AI card" height: f32 = 520.0, 200..=1200; "Height of the AI card"
server: String = String::from("http://localhost:11434"); "The Ollama server the AI card talks to" servers: String = String::from("http://localhost:11434"); "The Ollama servers the AI card talks to, comma-separated, in order: the model list shows all their models, a question goes to the first that has the chosen one"
model: String = String::from("gemma4:e4b"); "The Ollama model to chat with, with its tag (ollama list shows them)" model: String = String::from("gemma4:e4b"); "The Ollama model to chat with, with its tag (ollama list shows them)"
start_service: bool = true; "Start the local ollama service (doas rc-service ollama start) when it isn't answering" start_service: bool = true; "Start the local ollama service (doas rc-service ollama start) when it isn't answering"
system: String = String::from("You are a helpful assistant inside a Linux desktop shell. Answer briefly and plainly."); "What the model is told before every chat" system: String = String::from("You are a helpful assistant inside a Linux desktop shell. Answer briefly and plainly."); "What the model is told before every chat"
+51 -12
View File
@@ -37,17 +37,21 @@ pub struct Ui {
query: String, query: String,
message: Option<(String, bool)>, message: Option<(String, bool)>,
hover: Option<String>, hover: Option<String>,
// just opened, or a key went to the search: it takes the keys on the next frame
focus_search: bool,
} }
impl Service for Ui { impl Service for Ui {
fn new() -> Self { fn new() -> Self {
Self { page: String::from("appearance"), query: String::new(), message: None, hover: None } Self { page: String::from("appearance"), query: String::new(), message: None, hover: None, focus_search: false }
} }
fn listen() {} fn listen() {}
} }
pub fn open() { pub fn open() {
Ui::write().focus_search = true;
open_window(NAME, view); open_window(NAME, view);
hypr::load(); hypr::load();
@@ -158,6 +162,16 @@ fn say(result: Result<(), String>, done: String) {
pub fn view() -> Window { pub fn view() -> Window {
theme::begin(); theme::begin();
/*
* only on this frame: a TextInput that's .focused() takes the keys every frame it's
* drawn, so always focused, the search would take them back from a value being typed
*/
let focus_search = Ui::read().focus_search;
if focus_search {
Ui::write().focus_search = false;
}
let colors = theme::now(); let colors = theme::now();
let ui = Ui::read(); let ui = Ui::read();
let hypr = Hypr::read(); let hypr = Hypr::read();
@@ -283,22 +297,43 @@ pub fn view() -> Window {
.title(TITLE) .title(TITLE)
.size(1100.0, 760.0) .size(1100.0, 760.0)
.resizable(true) .resizable(true)
// Escape closes it, also from a field (a typed value only applies on Enter) /*
.on_key(|key| { * Escape closes it, also from a field (a typed value only applies on Enter). keys no
if key == Key::Escape { * field takes, like right after opening, are typed into the search, which then takes
close_window(NAME); * the keys itself: amane drops a field's focus whenever one of the shell's windows
} * loses the keyboard, as one does when this window opens
*/
.on_key(|key| match key {
Key::Escape => close_window(NAME),
Key::Character(letter) => search_key(Some(letter)),
Key::Space => search_key(Some(' ')),
Key::Backspace => search_key(None),
_ => {}
}) })
.child( .child(
Rectangle::new() Rectangle::new()
.width(width) .width(width)
.height(height) .height(height)
.fill(colors.surface_low) .fill(colors.surface_low)
.child(Row::new(children![sidebar(&ui, &hypr, height, &colors), main])), .child(Row::new(children![sidebar(&ui, &hypr, height, &colors, focus_search), main])),
) )
} }
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle { // a key typed with no field focused: into the search (none: Backspace), which takes the next keys
fn search_key(letter: Option<char>) {
let mut ui = Ui::write();
match letter {
Some(letter) => ui.query.push(letter),
None => {
ui.query.pop();
}
}
ui.focus_search = true;
}
fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors, focus_search: bool) -> Rectangle {
let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))]; let mut entries: Vec<Box<dyn Widget>> = vec![Box::new(caption("SHELL", colors))];
for module in config::modules() { for module in config::modules() {
@@ -333,11 +368,15 @@ fn sidebar(ui: &Ui, hypr: &Hypr, height: f32, colors: &Colors) -> Rectangle {
.padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 }) .padding(Padding { top: 0.0, right: 10.0, bottom: 0.0, left: 10.0 })
.align_child(Start, Center) .align_child(Start, Center)
.cursor(Cursor::Text) .cursor(Cursor::Text)
.child(TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| { .child({
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone())); let input = TextInput::new(SEARCH).placeholder("Search all settings").size(config::appearance::text::body()).color(colors.text).on_change(|text| {
SYNCED.with_borrow_mut(|synced| synced.insert(SEARCH, text.clone()));
Ui::write().query = text; Ui::write().query = text;
})); });
if focus_search { input.focused() } else { input }
});
Rectangle::new() Rectangle::new()
.width(SIDEBAR_W) .width(SIDEBAR_W)