Skip to content
Merged
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
63 changes: 51 additions & 12 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,21 +4,62 @@ on:
push:
branches:
- main
pull_request:
branches:
- main
types: [opened, synchronize, reopened, ready_for_review]
workflow_dispatch:

permissions:
contents: write
actions: write

# Cancel superseded runs on the same ref (e.g. new push to a PR branch).
concurrency:
group: release-${{ github.ref }}
cancel-in-progress: true

env:
CARGO_TERM_COLOR: always
# Workaround for transient "Error in the HTTP2 framing layer" failures
# when cargo fetches crates from the sparse registry on Windows runners.
CARGO_HTTP_MULTIPLEXING: "false"

jobs:
# ── Check if the version in Cargo.toml is new ──────────────────────────────
# ── CI: build + test on every push to main (gates the release) ─────────────
test:
name: Test — ${{ matrix.os }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [macos-latest, ubuntu-latest, windows-latest]
steps:
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Linux deps
if: matrix.os == 'ubuntu-latest'
run: |
sudo apt-get update
sudo apt-get install -y \
libxcb-render0-dev libxcb-shape0-dev libxcb-xfixes0-dev \
libxkbcommon-dev libwayland-dev libegl1-mesa-dev \
libfontconfig1-dev pkg-config
- name: Build
run: cargo build --all-targets
- name: Test
run: cargo test --all-targets

# ── Check the version in Cargo.toml is new (fails if not bumped) ───────────
check-version:
name: Check version
needs: test
runs-on: ubuntu-latest
outputs:
Comment on lines 55 to 59
version: ${{ steps.version.outputs.version }}
should_release: ${{ steps.check.outputs.should_release }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
with:
fetch-depth: 0
- name: Extract version from Cargo.toml
Expand All @@ -27,20 +68,18 @@ jobs:
VERSION=$(grep '^version' Cargo.toml | head -1 | sed 's/version = "\(.*\)"/\1/')
echo "version=$VERSION" >> $GITHUB_OUTPUT
echo "Detected version: $VERSION"
- name: Check if tag already exists
id: check
- name: Require a bumped version
run: |
if git tag | grep -qx "v${{ steps.version.outputs.version }}"; then
echo "should_release=false" >> $GITHUB_OUTPUT
else
echo "should_release=true" >> $GITHUB_OUTPUT
echo "::error::Version v${{ steps.version.outputs.version }} already released — bump 'version' in Cargo.toml."
exit 1
fi
echo "Version v${{ steps.version.outputs.version }} is new — proceeding."

# ── Build on all three platforms ───────────────────────────────────────────
build:
name: Build — ${{ matrix.os }}
needs: check-version
if: needs.check-version.outputs.should_release == 'true'
needs: [test, check-version]
runs-on: ${{ matrix.os }}
strategy:
matrix:
Expand All @@ -52,7 +91,7 @@ jobs:
- os: windows-latest
asset: llaunchpad-windows-x86_64.zip
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: dtolnay/rust-toolchain@stable

# ── macOS: universal .app ────────────────────────────────────────────
Expand Down Expand Up @@ -111,7 +150,7 @@ jobs:
needs: [check-version, build]
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v5
- uses: actions/download-artifact@v4
with:
merge-multiple: true
Expand Down
30 changes: 29 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,33 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.4.0] - 2026-06-01

### Added
- **Custom Ollama base URL** — point Llaunchpad at any Ollama server (local or remote).
A new `Ollama host` field plus a **Test** button probe `/api/version` and `/api/tags`,
report the server version, and persist the URL in prefs.
- **Local models in the dropdown** — after a successful test, the server's local models
are listed first with a teal **local** badge and a teal dot in the selector. Local
entries take precedence over cloud entries with the same name.
- **Dismissible status banner** — the success/error banner at the bottom now has a close
button (×) so it can be cleared without waiting for the next event.

Comment on lines +18 to +20
### Changed
- **CI** now triggers on pull requests as well as `main`, cancels superseded runs on the
same ref, and gates the release job on a **new** `version` in `Cargo.toml` — pushing
a build with an already-tagged version fails the workflow.
- Model list refresh preserves the currently selected model by name when the list is
rebuilt, so the highlight doesn't drop just because the list shape changed.

### Fixed
- **Windows**: font-based icons (`▾`, `×`, etc.) render as glyphs instead of boxes —
explicit `font-family` fallback list on the affected `Text` elements.
- **Shell command safety**: custom Ollama host URLs are validated/escaped before being
used in spawned shell commands.
- `ollama launch` argument order fixed so Codex / Claude Code pick up the selected
model reliably.

## [0.3.0] - 2026-05-31

### Added
Expand Down Expand Up @@ -88,7 +115,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Persisted last-used agent + model across runs.
- App icon, banner, and `bundle.sh` to assemble `Llaunchpad.app`.

[Unreleased]: https://github.com/draugvar/llaunchpad/compare/v0.3.0...HEAD
[Unreleased]: https://github.com/draugvar/llaunchpad/compare/v0.4.0...HEAD
[0.4.0]: https://github.com/draugvar/llaunchpad/compare/v0.3.0...v0.4.0
[0.3.0]: https://github.com/draugvar/llaunchpad/compare/v0.2.1...v0.3.0
[0.2.1]: https://github.com/draugvar/llaunchpad/compare/v0.2.0...v0.2.1
[0.2.0]: https://github.com/draugvar/llaunchpad/compare/v0.1.3...v0.2.0
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "llaunchpad"
version = "0.3.0"
version = "0.4.0"
edition = "2021"
description = "A cross-platform launcher for Ollama coding agents with cloud models"
license = "MIT"
Expand Down
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ the exact cloud model names, and re-typing the command every time.
|---|---|
| **Live agent list** | Parsed from `ollama launch --help` — always in sync with your Ollama version. |
| **Full cloud catalog** | Every model from `ollama.com/v1/models`, refreshed in the background every 5s. |
| **Custom Ollama host** | Point at any local or remote Ollama server; **Test** button probes the URL and pulls its local models. |
| **Local model badge** | Local models from your server show up first in the dropdown, tagged with a teal **local** badge. |
| **Correct model names** | Cloud ids are auto-normalized to launchable refs (`glm-4.6` → `glm-4.6:cloud`, `gpt-oss:120b` → `gpt-oss:120b-cloud`). |
| **GUI & CLI agents** | GUI apps (Codex, VS Code) are opened/relaunched; CLI agents spawn in Terminal. |
| **Codex App fix** | Strips the legacy `profile =` line modern Codex rejects, so launches just work. |
Expand Down Expand Up @@ -83,8 +85,10 @@ open Llaunchpad.app
## Usage

1. Open Llaunchpad.
2. Choose an **agent** and a **cloud model** from the inline dropdowns.
3. Hit **Launch**. The status bar confirms what was started.
2. (Optional) Paste your **Ollama host** URL (default `http://localhost:11434`) and hit
**Test** to pull that server's local models into the dropdown.
3. Choose an **agent** and a **model** from the inline dropdowns.
4. Hit **Launch**. The status bar confirms what was started. Click the × to dismiss it.

That's it — Llaunchpad runs `ollama launch <agent> --model <model> -y` under the hood and
brings the agent up configured against your chosen model.
Expand Down Expand Up @@ -128,10 +132,10 @@ Project layout:
```
src/
main.rs UI wiring, background refresh, state persistence
config.rs last-used selection (prefs.json)
config.rs last-used selection + ollama_host (prefs.json)
ollama/
agents.rs parse `ollama launch --help`
models.rs fetch + normalize cloud catalog
models.rs cloud catalog + local /api/tags + server probe
launch.rs spawn / quit / relaunch, Codex config fix
ui/app.slint Slint UI + theme
assets/ icon, banner, screenshot
Expand Down
34 changes: 22 additions & 12 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,21 @@ fn selected_model_name(ui: &AppWindow) -> Option<String> {
}
}

/// Replace the UI model list, re-resolving the selected index by name.
/// Indices are invalidated whenever the list is rebuilt (local models sit at
/// the front, so dropping them shifts cloud entries); resolving by name keeps
/// the highlight on the same model, or clears it (-1) if that model is gone.
fn set_models_preserving_selection(ui: &AppWindow, items: Vec<ModelItem>) {
let prev = selected_model_name(ui);
let new_idx = prev
.as_deref()
.and_then(|n| items.iter().position(|m| m.name == n))
.map(|i| i as i32)
.unwrap_or(-1);
ui.set_models(ModelRc::new(VecModel::from(items)));
ui.set_sel_model_index(new_idx);
}

/// Fetch agents, their running state, and the cloud model list.
async fn fetch_all() -> anyhow::Result<(Vec<Agent>, Vec<bool>, Vec<String>)> {
let agents = list_agents().await?;
Expand Down Expand Up @@ -168,15 +183,8 @@ fn main() -> anyhow::Result<()> {
if test_gen.load(Ordering::SeqCst) != gen { return; }
if let Some(ui) = ui_weak.upgrade() {
let cloud = cloud_names_from_ui(&ui);
let prev = selected_model_name(&ui);
let items = make_model_items(&fetched, &cloud);
let new_idx = prev.as_deref()
.and_then(|n| items.iter().position(|m| m.name == n))
.map(|i| i as i32);
ui.set_models(ModelRc::new(VecModel::from(items)));
if let Some(idx) = new_idx {
ui.set_sel_model_index(idx);
}
set_models_preserving_selection(&ui, items);
ui.set_status(msg.into());
ui.set_status_kind(1);
}
Expand All @@ -189,9 +197,10 @@ fn main() -> anyhow::Result<()> {
if test_gen.load(Ordering::SeqCst) != gen { return; }
if let Some(ui) = ui_weak.upgrade() {
let cloud = cloud_names_from_ui(&ui);
ui.set_models(ModelRc::new(VecModel::from(
set_models_preserving_selection(
&ui,
make_model_items(&[], &cloud),
)));
);
ui.set_status(msg.into());
ui.set_status_kind(1);
}
Expand All @@ -206,9 +215,10 @@ fn main() -> anyhow::Result<()> {
if test_gen.load(Ordering::SeqCst) != gen { return; }
if let Some(ui) = ui_weak.upgrade() {
let cloud = cloud_names_from_ui(&ui);
ui.set_models(ModelRc::new(VecModel::from(
set_models_preserving_selection(
&ui,
make_model_items(&[], &cloud),
)));
);
ui.set_status(msg.into());
ui.set_status_kind(2);
}
Expand Down
13 changes: 10 additions & 3 deletions src/ollama/launch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,15 @@ pub fn agent_running(agent: &Agent) -> bool {

// ───────────────────────── platform helpers ─────────────────────────

/// Strip characters that could be interpreted as shell metacharacters from a URL.
/// Strip everything that is not part of a plain base URL, so the result is safe
/// to interpolate into a shell command line. The retained set covers
/// scheme/host/port plus IPv6 literals (`[::1]`) and optional userinfo (`@`).
/// Shell metacharacters (`& # ? % = \ | ; < > $ ` ` ` "` `'` space) are dropped —
/// they have no place in a base URL and `&`/`%` are command separators / env
/// expansions on cmd.exe and POSIX shells.
fn shell_safe_url(url: &str) -> String {
url.chars()
.filter(|c| c.is_ascii_alphanumeric() || "://.\\-_@%+=?&#[]".contains(*c))
.filter(|c| c.is_ascii_alphanumeric() || "://.-_@[]".contains(*c))
.collect()
}

Expand All @@ -80,8 +85,10 @@ fn spawn_in_terminal(cmd: &str, ollama_host: Option<&str>) -> Result<()> {
let full_cmd: String;
let cmd = if let Some(host) = ollama_host {
let safe = shell_safe_url(host);
// Quote the assignment so cmd.exe does not fold the space before `&&`
// into the value (`set VAR=x ` would store a trailing space).
#[cfg(target_os = "windows")]
{ full_cmd = format!("set OLLAMA_HOST={safe} && {cmd}"); }
{ full_cmd = format!("set \"OLLAMA_HOST={safe}\"&& {cmd}"); }
#[cfg(not(target_os = "windows"))]
{ full_cmd = format!("OLLAMA_HOST={safe} {cmd}"); }
full_cmd.as_str()
Expand Down
7 changes: 4 additions & 3 deletions ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -365,10 +365,11 @@ component Banner {
? root.fg.with-alpha(0.18)
: transparent;
Text {
text: "";
text: "×";
color: root.fg;
font-size: 11px;
font-weight: 700;
font-family: "Arial, Helvetica, Liberation Sans, DejaVu Sans, sans-serif";
font-size: 16px;
font-weight: 400;
horizontal-alignment: center;
vertical-alignment: center;
}
Expand Down
Loading