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
4 changes: 2 additions & 2 deletions extensions/ai/backslash.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
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.1.0"
version = "1.2.0"
author = "Backslash"
icon = "✨"
icon = "bs-sparkle"
license = "MIT"

# The text commands work on what you copied last.
Expand Down
30 changes: 22 additions & 8 deletions extensions/ai/chat.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { action, clipboard, close, detail, field, form, replace, storage } from "@backslash/api";
import { languageRule, type Message, type Prefs } from "./lib.ts";
import { action, clipboard, close, detail, field, form, Icons, meta, replace, storage } from "@backslash/api";
import { languageRule, type Message, type Prefs, provider } from "./lib.ts";
import { canPaste, pasteText } from "./paste.ts";
import { answer, modelName } from "./llm.ts";
import { answer } from "./llm.ts";

export interface Conversation {
id: string;
Expand Down Expand Up @@ -29,6 +29,19 @@ const system = (prefs: Prefs) =>
languageRule(prefs)
}`;

const questions = (c: Conversation) => c.messages.filter((m) => m.role === "user").length;

function chatMetadata(prefs: Prefs, c: Conversation) {
const p = provider(prefs);
return [
meta.text("Provider", p.name, { icon: Icons.sparkle, color: "purple" }),
meta.text("Model", p.model, { icon: Icons.cpu }),
meta.separator(),
meta.text("Questions", questions(c), { icon: Icons.comment }),
meta.text("Updated", new Date(c.updated).toLocaleString(), { icon: Icons.clock }),
];
}

/** "**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");
Expand All @@ -54,7 +67,8 @@ export function chat(prefs: Prefs, previous: Conversation | undefined, question?

return detail({
title: convo.title,
metadata: { Model: modelName(prefs) },
metadata: chatMetadata(prefs, convo),
status: `${questions(convo)} question${questions(convo) === 1 ? "" : "s"}`,
markdown: question
? async function* ({ signal }) {
const before = transcript(convo.messages);
Expand All @@ -71,13 +85,13 @@ export function chat(prefs: Prefs, previous: Conversation | undefined, question?
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" }),
...(canPaste ? [action("Paste Answer", () => pasteText(last), { icon: "edit-paste", shortcut: "<Ctrl>p" })] : []),
}, { icon: Icons.copy }),
action("Ask a Follow-up…", () => followUp, { icon: Icons.comment, shortcut: "<Ctrl>r" }),
...(canPaste ? [action("Paste Answer", () => pasteText(last), { icon: Icons.paste, shortcut: "<Ctrl>p" })] : []),
action("Copy Conversation", async () => {
await clipboard.write(transcript(convo.messages));
return close("Conversation copied");
}),
}, { icon: Icons.copy, section: "Copy" }),
],
});
}
91 changes: 65 additions & 26 deletions extensions/ai/commands/ask.ts
Original file line number Diff line number Diff line change
@@ -1,50 +1,89 @@
import { action, type Item, list, refresh } from "@backslash/api";
import { chat, conversations, remove } from "../chat.ts";
import { action, Icons, type Item, list, meta, refresh } from "@backslash/api";
import { chat, type Conversation, conversations, remove } from "../chat.ts";
import type { Prefs } from "../lib.ts";
import { modelName } from "../llm.ts";

const ago = (ms: number) => {
const short = (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`;
return m < 1 ? "now" : m < 60 ? `${m}m` : m < 1440 ? `${Math.floor(m / 60)}h` : `${Math.floor(m / 1440)}d`;
};

/** Today, Yesterday, This Week, Older: like the clipboard history. */
export function when(ms: number, now = new Date()): string {
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
if (ms >= day) return "Today";
if (ms >= day - 86_400_000) return "Yesterday";
if (ms >= day - 6 * 86_400_000) return "This Week";
return "Older";
}

const oneLine = (s: string) => s.replace(/[#*_`>]/g, "").replace(/\s+/g, " ").trim();

function conversationItem(prefs: Prefs, c: Conversation, q: string): Item {
const lastAnswer = [...c.messages].reverse().find((m) => m.role === "assistant")?.content ?? "";
const lastQuestion = [...c.messages].reverse().find((m) => m.role === "user")?.content ?? "";
const questions = c.messages.filter((m) => m.role === "user").length;
return {
id: c.id,
title: c.title,
subtitle: oneLine(lastAnswer).slice(0, 80),
icon: { source: Icons.comment, tint: "purple" },
section: when(c.updated),
accessories: [
...(questions > 1 ? [{ icon: Icons.comment, text: String(questions), tooltip: `${questions} questions` }] : []),
{ text: short(c.updated), tooltip: new Date(c.updated).toLocaleString() },
],
detail: {
markdown: `> ${oneLine(lastQuestion).slice(0, 200)}\n\n${
lastAnswer.length > 1200 ? `${lastAnswer.slice(0, 1200)}…` : lastAnswer
}`,
metadata: [
meta.text("Questions", questions, { icon: Icons.comment }),
meta.text("Updated", new Date(c.updated).toLocaleString(), { icon: Icons.clock }),
],
},
actions: [
action("Open Conversation", () => chat(prefs, c), { icon: Icons.comment }),
...(q ? [action(`Continue with “${q}”`, () => chat(prefs, c, q), { icon: Icons.arrowRight })] : []),
action("Delete Conversation", async () => {
await remove(c.id);
return refresh();
}, { icon: Icons.trash, destructive: true, confirm: "Delete this conversation?", section: "Danger" }),
],
};
}

let count = 0;

export default list({
placeholder: "Ask anything…",
empty: { title: "Ask anything", description: "Type a question, then press Enter" },
empty: { title: "Ask anything", description: "Type a question, then press Enter", icon: Icons.sparkle },
status: () => `${count} conversation${count === 1 ? "" : "s"}`,
async search(query, { preferences }) {
const prefs = preferences as Prefs;
const q = query.trim();
const items: Item[] = [];
if (q) {
let model = "";
try {
model = modelName(prefs);
} catch { /* shown when asking */ }
items.push({
id: "ask",
title: q,
subtitle: "Ask AI",
icon: "✨",
icon: { source: Icons.sparkle, tint: "purple" },
section: "Ask",
actions: [action("Ask", () => chat(prefs, undefined, q), { icon: "mail-send" })],
accessories: model ? [{ tag: model }] : [],
actions: [action("Ask", () => chat(prefs, undefined, q), { icon: Icons.sparkle })],
});
}
const all = await conversations();
count = all.length;
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
for (const c of await conversations()) {
for (const c of all) {
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?" }),
],
});
if (words.every((w) => text.includes(w))) items.push(conversationItem(prefs, c, q));
}
return items;
},
Expand Down
7 changes: 5 additions & 2 deletions extensions/ai/commands/translate.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { action, type Context, type DetailSpec, replace } from "@backslash/api";
import { action, type Context, type DetailSpec, Icons, replace } from "@backslash/api";
import type { Prefs } from "../lib.ts";
import { copiedText, nothingCopied, toolView } from "../tools.ts";

Expand All @@ -11,7 +11,10 @@ function translate(prefs: Prefs, text: string, to: string): DetailSpec {
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" })
action(`Translate into ${l}`, () => replace(translate(prefs, text, l)), {
icon: Icons.globe,
section: "Other Languages",
})
);
return toolView(prefs, `Translate into ${to}`, instructions, text, others);
}
Expand Down
20 changes: 13 additions & 7 deletions extensions/ai/tools.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { type Action, action, clipboard, close, type Context, detail, replace } from "@backslash/api";
import { type Action, action, clipboard, close, type Context, detail, Icons, meta, replace } from "@backslash/api";
import { chat } from "./chat.ts";
import { languageRule, type Prefs } from "./lib.ts";
import { languageRule, type Prefs, provider } from "./lib.ts";
import { canPaste, pasteText } from "./paste.ts";
import { answer, modelName } from "./llm.ts";
import { answer } from "./llm.ts";

/** The streamed result of an instruction applied to `text`; Enter copies it. */
export function toolView(
Expand All @@ -16,27 +16,33 @@ export function toolView(
let result = "";
return detail({
title,
metadata: { Text: text.length > 400 ? `${text.slice(0, 400)}…` : text, Model: modelName(prefs) },
metadata: [
meta.text("Model", provider(prefs).model, { icon: Icons.sparkle, color: "purple" }),
meta.text("Words", text.split(/\s+/).filter(Boolean).length, { icon: Icons.text }),
meta.separator(),
meta.text("Text", text.length > 400 ? `${text.slice(0, 400)}…` : text),
],
status: provider(prefs).name,
async *markdown({ signal }) {
for await (const t of answer(prefs, system, [{ role: "user", content: text }], signal, "low")) {
result = t;
yield t;
}
},
actions: [
...(canPaste ? [action("Paste Result", () => pasteText(result), { icon: "edit-paste" })] : []),
...(canPaste ? [action("Paste Result", () => pasteText(result), { icon: Icons.paste })] : []),
action("Copy Result", async () => {
await clipboard.write(result);
return close("Copied");
}, { icon: "edit-copy" }),
}, { icon: Icons.copy }),
...more,
action("Continue in Chat", () =>
replace(chat(prefs, {
id: crypto.randomUUID(),
title: `${title}: ${text.slice(0, 60)}`,
messages: [{ role: "user", content: `${title}:\n\n${text}` }, { role: "assistant", content: result }],
updated: Date.now(),
})), { icon: "mail-reply-sender", shortcut: "<Ctrl>r" }),
})), { icon: Icons.comment, shortcut: "<Ctrl>r", section: "Chat" }),
],
});
}
Expand Down
4 changes: 2 additions & 2 deletions extensions/browser/backslash.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
id = "browser"
title = "Browser"
description = "History, bookmarks and open tabs of Firefox, Chrome, Brave, Chromium, Edge and Vivaldi"
version = "1.0.0"
version = "1.1.0"
author = "Backslash"
icon = "web-browser"
icon = "bs-globe"
license = "MIT"

# Browsers lock their databases: they are copied to /tmp/backslash-browser to be read.
Expand Down
50 changes: 41 additions & 9 deletions extensions/browser/commands/search.ts
Original file line number Diff line number Diff line change
@@ -1,36 +1,68 @@
import { copy, type Item, list, open } from "@backslash/api";
import { copy, Icons, type Item, list, meta, open } from "@backslash/api";
import { favicons, type Page, pages, profiles } from "../browsers.ts";
import { ago, domain } from "../lib.ts";

const when = (ms: number) =>
new Date(ms).toLocaleString("en", {
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
});

function item(p: Page, several: boolean, icon?: string): Item {
const host = domain(p.url);
return {
id: p.url,
title: p.title,
id: `${p.bookmark ? "b" : "h"}:${p.url}`,
title: p.title || host,
subtitle: host,
icon: icon ?? (p.bookmark ? "⭐" : "web-browser"),
icon: icon ?? (p.bookmark ? { source: Icons.starFilled, tint: "yellow" } : { source: Icons.globe, tint: "gray" }),
section: p.bookmark ? "Bookmarks" : "History",
keywords: [p.url, host],
accessories: [
...(several ? [{ tag: p.browser }] : []),
...(p.visits > 1 ? [{ text: `${p.visits}×`, tooltip: `${p.visits} visits` }] : []),
...(several ? [{ text: p.browser }] : []),
...(p.time > 0 ? [{ text: ago(p.time) }] : []),
],
detail: {
metadata: [
meta.link("Address", p.url.length > 80 ? `${p.url.slice(0, 80)}…` : p.url, p.url),
meta.text("Site", host, { icon: Icons.globe }),
...(p.bookmark ? [meta.text("Bookmark", "Yes", { icon: Icons.starFilled, color: "yellow" })] : []),
...(p.time > 0 ? [meta.text(p.bookmark ? "Added" : "Last visit", when(p.time), { icon: Icons.clock })] : []),
...(p.visits ? [meta.text("Visits", p.visits)] : []),
meta.text("Browser", p.browser),
],
},
actions: [
open(p.url, "Open in Browser"),
copy(p.url, "Copy URL"),
copy(`[${p.title}](${p.url})`, "Copy as Markdown Link"),
copy(p.url, "Copy URL", { shortcut: "<Ctrl><Shift>c" }),
copy(`[${p.title}](${p.url})`, "Copy as Markdown Link", { section: "Copy" }),
copy(p.title, "Copy Title", { section: "Copy" }),
],
};
}

export default list({
placeholder: "Search history and bookmarks…",
filter: {
options: [
{ value: "all", title: "All" },
{ value: "bookmarks", title: "Bookmarks" },
{ value: "history", title: "History" },
],
},
empty: {
title: "No browser found",
description: "Firefox, Chrome, Brave, Chromium, Edge and Vivaldi are supported",
icon: "bs-globe",
},
async *items() {
const all = pages();
status: (items) => `${items.length} page${items.length === 1 ? "" : "s"}`,
async *items({ filter }) {
let all = pages();
if (filter === "bookmarks") all = all.filter((p) => p.bookmark);
if (filter === "history") all = all.filter((p) => !p.bookmark);
const several = new Set(profiles().map((p) => p.browser)).size > 1;
// Bookmarks first, then history from the most recent visit.
all.sort((a, b) => Number(b.bookmark) - Number(a.bookmark) || b.time - a.time);
Expand Down
22 changes: 18 additions & 4 deletions extensions/browser/commands/tabs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { copy, list, open } from "@backslash/api";
import { copy, Icons, list, meta, open } from "@backslash/api";
import { profiles } from "../browsers.ts";
import { ago, domain, firefoxTabs, mozLz4, type Tab } from "../lib.ts";

Expand All @@ -17,15 +17,29 @@ function readTabs(): Tab[] {

export default list({
placeholder: "Search open tabs…",
empty: { title: "No open tabs", description: "Firefox's session file was not found" },
empty: { title: "No open tabs", description: "Firefox's session file was not found", icon: "bs-window" },
status: (items) => `${items.length} open tab${items.length === 1 ? "" : "s"}`,
items: () =>
readTabs().map((t) => ({
id: t.url,
title: t.title,
subtitle: domain(t.url),
icon: t.pinned ? "📌" : "web-browser",
icon: t.pinned ? { source: Icons.pin, tint: "orange" as const } : { source: Icons.globe, tint: "gray" as const },
section: t.pinned ? "Pinned" : "Tabs",
keywords: [t.url],
accessories: t.lastAccessed ? [{ text: ago(t.lastAccessed) }] : [],
actions: [open(t.url, "Open in Browser"), copy(t.url, "Copy URL")],
detail: {
metadata: [
meta.link("Address", t.url.length > 80 ? `${t.url.slice(0, 80)}…` : t.url, t.url),
meta.text("Site", domain(t.url), { icon: Icons.globe }),
...(t.lastAccessed ? [meta.text("Last seen", ago(t.lastAccessed), { icon: Icons.clock })] : []),
...(t.pinned ? [meta.text("Pinned", "Yes", { icon: Icons.pin })] : []),
],
},
actions: [
open(t.url, "Open in Browser"),
copy(t.url, "Copy URL", { shortcut: "<Ctrl><Shift>c" }),
copy(`[${t.title}](${t.url})`, "Copy as Markdown Link"),
],
})),
});
4 changes: 2 additions & 2 deletions extensions/docker/backslash.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@
id = "docker"
title = "Containers"
description = "Docker and Podman containers: start, stop, logs"
version = "1.0.0"
version = "1.1.0"
author = "Backslash"
icon = "utilities-terminal"
icon = "bs-box"
license = "MIT"

[permissions]
Expand Down
Loading
Loading