Official
chat
Built-in chat UI (JavaScript UI plugin).
The app opens the listing; nothing installs until an agent in your Plugins workspace has read the files and you enable the plugin. In a terminal: cvg install convergence/chat@0.2.0
Permissions in 0.2.0
Files
switcher.ts3.4 KB
// The Ctrl+Tab chat switcher's model: which chats the cycle holds, where
// the selection sits in it, and which slice of it is on screen.
//
// The cycle is a selection, not a switch. Holding Ctrl and tapping Tab
// moves the selection; releasing Ctrl, or pressing Enter, opens the chat
// it landed on. Escape leaves the chat the user was in. Nothing loads
// while the cycle runs, so a long list costs one render per tap.
//
// Pure functions over plain objects, like layout.js: the plugin holds
// `{ list, selected }` while the cycle is open, and nothing else.
export type CycleChat = { id: string; archived?: boolean; updatedAt?: string };
export type Cycle = { list: string[]; selected: string };
/// How many chats the cycle holds. Past this the list is no longer a
/// memory of what the user was doing.
export const CYCLE_LIMIT = 30;
/// Rows drawn at once. A longer list scrolls under the selection.
export const VISIBLE = 10;
/// Rows kept above the selection once the list scrolls, so the next few
/// chats in the cycle stay in sight.
const LEAD = 4;
/// The cycle order: the chats last worked in first, then every other chat
/// by its own last activity. The active chat always leads, because a tap
/// of Tab counts from where the user is now.
export function cycle(
chats: Record<string, CycleChat>,
recent: string[],
activeId: string | undefined,
limit = CYCLE_LIMIT,
) {
const open = (chat: CycleChat | undefined) => chat && !chat.archived;
const ids = recent.filter((id) => open(chats[id]));
const seen = new Set(ids);
const rest = Object.values(chats)
.filter((chat) => open(chat) && !seen.has(chat.id))
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""))
.map((chat) => chat.id);
const all = [...ids, ...rest];
const ordered = activeId && all.includes(activeId) ? [activeId, ...all.filter((id) => id !== activeId)] : all;
return ordered.slice(0, limit);
}
/// Opens the cycle, or moves the selection inside one already open. The
/// list is built once, when it opens, so it does not reorder under the
/// user's hand. Returns null when there is nowhere to go.
export function step(
open: Cycle | null,
chats: Record<string, CycleChat>,
recent: string[],
activeId: string | undefined,
delta: number,
): Cycle | null {
const list = open ? open.list : cycle(chats, recent, activeId);
if (list.length < 2) return null;
const basis = open ? open.selected : activeId;
const at = Math.max(0, list.indexOf(basis ?? ""));
return { list, selected: list[(at + delta + list.length) % list.length] ?? list[0] ?? "" };
}
/// Drops chats the app no longer has, and keeps the selection on a chat
/// that is still there. Returns null when fewer than two are left.
export function reconcile(open: Cycle | null, chats: Record<string, CycleChat>): Cycle | null {
if (!open) return null;
const list = open.list.filter((id) => chats[id] && !chats[id].archived);
if (list.length < 2) return null;
return { list, selected: list.includes(open.selected) ? open.selected : (list[0] ?? "") };
}
/// The rows to draw, with the selection in sight. `from` is where the
/// slice starts, for a caller that numbers its rows.
export function visible(list: string[], selected: string, max = VISIBLE) {
if (list.length <= max) return { rows: list, from: 0 };
const at = Math.max(0, list.indexOf(selected));
const from = Math.min(Math.max(0, at - LEAD), list.length - max);
return { rows: list.slice(from, from + max), from };
}Versions
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 105.1 KB | 5 permissions | Listed |
No comments yet.