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

  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Send prompts, answer agent questions, and create, change, fork, revert, stop and group chats
  • Run other plugins' commands commands.runHighRuns commands of other plugins.Run the sidebar's New chat command from the title bar
  • Read chats chats.readMediumReads your transcripts and chat lists.Show workspaces, tasks, chats and transcripts, and follow them as agents work
  • Read files fs.readMediumReads files in the listed places.Suggest workspace files for @ mentionsPlaces: the open workspace
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the chat grid and the title barSlots: centertitle

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

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <3105.1 KB5 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.