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
1 change: 1 addition & 0 deletions .github/workflows/check.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,6 @@ jobs:
- run: deno fmt --check extensions scripts
- run: deno lint extensions scripts
- run: deno check extensions/*/commands/*.ts
- run: deno test extensions
- name: Verify registry entries (clones each extension at its commit)
run: deno run -A scripts/validate.ts
22 changes: 15 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,20 @@ Anyone can also install straight from a repository, without the registry:

## First-party extensions

| Extension | Commands | Permissions |
| ----------------------------------- | ---------------------------------------------- | ------------------------------------ |
| [GitHub](extensions/github) | Search repositories, my pull requests | `api.github.com` |
| [Packages](extensions/packages) | Search apt/dnf/pacman/zypper and Flathub | runs the package tools (search only) |
| [Services](extensions/systemd) | Start, stop, restart, logs of systemd services | `systemctl`, `journalctl` |
| [Containers](extensions/docker) | Docker/Podman: start, stop, logs, remove | `docker`, `podman` |
| [Emoji & Symbols](extensions/emoji) | Search and copy | none |
| Extension | Commands | Permissions |
| --------------------------------------- | ------------------------------------------------------------------------------------------- | ------------------------------------------------- |
| [GitHub](extensions/github) | Search repositories, my pull requests | `api.github.com` |
| [Packages](extensions/packages) | Search apt/dnf/pacman/zypper and Flathub | runs the package tools (search only) |
| [Services](extensions/systemd) | Start, stop, restart, logs of systemd services | `systemctl`, `journalctl` |
| [Containers](extensions/docker) | Docker/Podman: start, stop, logs, remove | `docker`, `podman` |
| [Emoji & Symbols](extensions/emoji) | Search and copy | none |
| [Browser](extensions/browser) | History and bookmarks of Firefox, Chrome, Brave, Chromium, Edge, Vivaldi; Firefox tabs | reads the browsers' profiles |
| [Media Controls](extensions/media) | Now playing, play/pause, next, volume of any MPRIS player | `busctl`, Spotify covers |
| [Code Projects](extensions/projects) | Recent projects of VS Code, Cursor, Zed, JetBrains, git folders; open in editor or terminal | reads editor history, runs editors and terminals |
| [Timers](extensions/timers) | Timers and Pomodoro that ring even with Backslash closed | `systemd-run`, `systemctl` |
| [Ports](extensions/ports) | What listens on each port, with its process and project; stop it | `ss`, `ps`, `readlink`, `kill` |
| [Network & Devices](extensions/network) | Wi-Fi, Bluetooth, audio outputs, VPN and Tailscale | `nmcli`, `bluetoothctl`, `pactl`, `tailscale` |
| [AI](extensions/ai) | Ask AI, fix, improve, summarize, translate, explain copied text | Claude, OpenAI, Mistral, Groq, OpenRouter, Ollama |

Their registry entries are regenerated with `scripts/update-entries.sh` after a change is merged.

Expand All @@ -77,5 +84,6 @@ Their registry entries are regenerated with `scripts/update-entries.sh` after a
```sh
git clone https://github.com/backslash-app/backslash .backslash # for the SDK types
deno check extensions/*/commands/*.ts
deno test extensions # parsers of each extension (lib_test.ts)
backslash dev extensions/github # live reload in the launcher
```
15 changes: 12 additions & 3 deletions deno.json
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
{
"imports": {
"@backslash/api": "./.backslash/sdk/mod.ts",
"@std/toml": "jsr:@std/toml@^1"
"@std/toml": "jsr:@std/toml@^1",
"@std/assert": "jsr:@std/assert@^1"
},
"fmt": { "lineWidth": 120 },
"lint": { "rules": { "exclude": ["no-explicit-any"] } }
"fmt": {
"lineWidth": 120
},
"lint": {
"rules": {
"exclude": [
"no-explicit-any"
]
}
}
}
12 changes: 12 additions & 0 deletions deno.lock

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

76 changes: 76 additions & 0 deletions extensions/ai/backslash.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
[extension]
id = "ai"
title = "AI"
description = "Ask questions, fix, rewrite, summarize, translate and explain text, with Claude, OpenAI, Mistral, Groq, OpenRouter or a local Ollama"
version = "1.0.0"
author = "Backslash"
icon = "✨"
license = "MIT"

# The text commands work on what you copied last.
[permissions]
net = ["api.anthropic.com", "api.openai.com", "api.mistral.ai", "api.groq.com", "openrouter.ai", "localhost:11434"]
clipboard-read = true

[[commands]]
name = "ask"
title = "Ask AI"
description = "Ask anything; your conversations are kept"
keywords = ["ai", "chat", "claude", "gpt", "llm", "question", "ia", "assistant"]

[[commands]]
name = "fix"
title = "Fix Spelling and Grammar"
description = "Corrects the text you copied"
keywords = ["ai", "spelling", "grammar", "orthographe", "corriger", "typo"]

[[commands]]
name = "improve"
title = "Improve Writing"
description = "Rewrites the text you copied, clearer"
keywords = ["ai", "rewrite", "reformuler", "améliorer", "style"]

[[commands]]
name = "summarize"
title = "Summarize"
description = "Summarizes the text you copied"
keywords = ["ai", "summary", "résumé", "tldr"]

[[commands]]
name = "translate"
title = "Translate"
description = "Translates the text you copied"
keywords = ["ai", "translate", "traduire", "traduction", "language"]

[[commands]]
name = "explain"
title = "Explain"
description = "Explains the text or code you copied"
keywords = ["ai", "explain", "expliquer", "code", "what is"]

[[preferences]]
name = "provider"
title = "Provider"
description = "anthropic, openai, mistral, groq, openrouter, or ollama (local, no key)"
default = "anthropic"

[[preferences]]
name = "api_key"
title = "API key"
description = "Kept in your system keyring. Not needed for Ollama."
type = "secret"

[[preferences]]
name = "model"
title = "Model"
description = "Leave empty for the provider's default (Claude Opus 5.5 for Anthropic)"

[[preferences]]
name = "language"
title = "Answer language"
description = "e.g. French. Empty: the language of your question or text."

[[preferences]]
name = "translate_to"
title = "Translate into"
default = "English"
81 changes: 81 additions & 0 deletions extensions/ai/chat.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import { action, clipboard, close, detail, field, form, replace, storage } from "@backslash/api";
import { languageRule, type Message, type Prefs } from "./lib.ts";
import { answer, modelName } from "./llm.ts";

export interface Conversation {
id: string;
title: string;
messages: Message[];
updated: number;
}

const MAX = 50;

export const conversations = async (): Promise<Conversation[]> =>
((await storage.get("conversations")) ?? []) as unknown as Conversation[];

async function save(c: Conversation) {
const all = (await conversations()).filter((x) => x.id !== c.id);
await storage.set("conversations", [c, ...all].slice(0, MAX) as any);
}

export async function remove(id: string) {
await storage.set("conversations", (await conversations()).filter((c) => c.id !== id) as any);
}

const system = (prefs: Prefs) =>
`You are a helpful assistant opened from a desktop launcher on Linux. Be direct and concise; use Markdown (short paragraphs, lists, fenced code blocks) when it helps. ${
languageRule(prefs)
}`;

/** "**You** question" then the answer, for each turn. */
function transcript(messages: Message[]): string {
return messages.map((m) => (m.role === "user" ? `> ${m.content.replace(/\n/g, "\n> ")}` : m.content)).join("\n\n");
}

/**
* A conversation view: asks `question` (if any) after `previous` and streams
* the answer below the earlier turns.
*/
export function chat(prefs: Prefs, previous: Conversation | undefined, question?: string) {
const convo: Conversation = previous
? { ...previous, messages: [...previous.messages] }
: { id: crypto.randomUUID(), title: question ?? "", messages: [], updated: Date.now() };
if (question) convo.messages.push({ role: "user", content: question });
let last = [...convo.messages].reverse().find((m) => m.role === "assistant")?.content ?? "";

const followUp = form({
title: "Follow-up",
submitTitle: "Ask",
fields: [field.textArea("question", "Your question", { required: true })],
submit: (values) => replace(chat(prefs, convo, String(values.question))),
});

return detail({
title: convo.title,
metadata: { Model: modelName(prefs) },
markdown: question
? async function* ({ signal }) {
const before = transcript(convo.messages);
for await (const text of answer(prefs, system(prefs), convo.messages, signal)) {
last = text;
yield `${before}\n\n${text}`;
}
convo.messages.push({ role: "assistant", content: last });
convo.updated = Date.now();
await save(convo);
}
: transcript(convo.messages),
actions: [
action("Copy Answer", async () => {
await clipboard.write(last);
return close("Answer copied");
}, { icon: "edit-copy" }),
action("Ask a Follow-up…", () => followUp, { icon: "mail-reply-sender", shortcut: "<Ctrl>r" }),
action("Copy Conversation", async () => {
await clipboard.write(transcript(convo.messages));
return close("Conversation copied");
}),
],
});
}
51 changes: 51 additions & 0 deletions extensions/ai/commands/ask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { action, type Item, list, refresh } from "@backslash/api";
import { chat, conversations, remove } from "../chat.ts";
import type { Prefs } from "../lib.ts";

const ago = (ms: number) => {
const m = Math.floor((Date.now() - ms) / 60000);
return m < 60 ? `${m} min ago` : m < 1440 ? `${Math.floor(m / 60)} h ago` : `${Math.floor(m / 1440)} d ago`;
};

export default list({
placeholder: "Ask anything…",
empty: { title: "Ask anything", description: "Type a question, then press Enter" },
async search(query, { preferences }) {
const prefs = preferences as Prefs;
const q = query.trim();
const items: Item[] = [];
if (q) {
items.push({
id: "ask",
title: q,
subtitle: "Ask AI",
icon: "✨",
section: "Ask",
actions: [action("Ask", () => chat(prefs, undefined, q), { icon: "mail-send" })],
});
}
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
for (const c of await conversations()) {
const text = c.messages.map((m) => m.content).join(" ").toLowerCase();
if (!words.every((w) => text.includes(w))) continue;
const lastAnswer = [...c.messages].reverse().find((m) => m.role === "assistant")?.content ?? "";
items.push({
id: c.id,
title: c.title,
subtitle: lastAnswer.replace(/\s+/g, " ").slice(0, 120),
icon: "💬",
section: "Conversations",
accessories: [{ text: ago(c.updated) }],
actions: [
action("Open Conversation", () => chat(prefs, c), { icon: "document-open" }),
...(q ? [action(`Continue with “${q}”`, () => chat(prefs, c, q), { icon: "mail-reply-sender" })] : []),
action("Delete", async () => {
await remove(c.id);
return refresh();
}, { destructive: true, confirm: "Delete this conversation?" }),
],
});
}
return items;
},
});
9 changes: 9 additions & 0 deletions extensions/ai/commands/explain.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { languageRule, textTool } from "../tools.ts";

export default textTool(
"Explain",
(prefs) =>
`Explain the user's text simply: what it means or does, and any jargon or code in it. Be brief. ${
languageRule(prefs)
}`,
);
7 changes: 7 additions & 0 deletions extensions/ai/commands/fix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { textTool } from "../tools.ts";

export default textTool(
"Fix",
() =>
"Fix the spelling, grammar and punctuation of the user's text. Keep its language, meaning, tone and formatting. Reply with the corrected text only, nothing else.",
);
7 changes: 7 additions & 0 deletions extensions/ai/commands/improve.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { textTool } from "../tools.ts";

export default textTool(
"Improve",
() =>
"Rewrite the user's text so it reads clearer and more natural, without changing its meaning, language or level of formality. Reply with the rewritten text only.",
);
6 changes: 6 additions & 0 deletions extensions/ai/commands/summarize.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { languageRule, textTool } from "../tools.ts";

export default textTool(
"Summarize",
(prefs) => `Summarize the user's text in a few short bullet points, most important first. ${languageRule(prefs)}`,
);
23 changes: 23 additions & 0 deletions extensions/ai/commands/translate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { action, type Context, type DetailSpec, replace } from "@backslash/api";
import type { Prefs } from "../lib.ts";
import { copiedText, nothingCopied, toolView } from "../tools.ts";

const LANGUAGES = ["English", "French", "Spanish", "German", "Italian", "Portuguese", "Chinese", "Japanese"];

function translate(prefs: Prefs, text: string, to: string): DetailSpec {
// Text already in the target language goes to the user's own language.
const other = [prefs.language?.trim(), "English"].find((l) => l && l.toLowerCase() !== to.toLowerCase());
const instructions = `Translate the user's text into ${to}.${
other ? ` If it is already in ${to}, translate it into ${other}.` : ""
} Keep the formatting. Reply with the translation only.`;
const others = LANGUAGES.filter((l) => l.toLowerCase() !== to.toLowerCase()).map((l) =>
action(`Translate into ${l}`, () => replace(translate(prefs, text, l)), { icon: "preferences-desktop-locale" })
);
return toolView(prefs, `Translate into ${to}`, instructions, text, others);
}

export default async (ctx: Context) => {
const prefs = ctx.preferences as Prefs;
const text = await copiedText();
return text ? translate(prefs, text, prefs.translate_to?.trim() || "English") : nothingCopied("translate");
};
Loading
Loading