Official

settings

Built-in settings 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/settings@0.2.0

Permissions in 0.2.0

  • Manage plugins plugins.manageOfficial onlyReloads and enables plugins. Only official plugins can have it.List, enable, disable and reload plugins, install updates, and quit to finish an update
  • Change app settings settings.writeHighChanges the app settings.Change the settings and the theme
  • Manage agents agents.manageHighEnables, disables and signs in agents, and changes their settings.Turn agents on and off, sign in to them and update them
  • Control chats chats.controlHighCreates chats, sends prompts and cancels runs.Import an agent's own sessions as chats
  • Read chats chats.readMediumReads your transcripts and chat lists.List the workspaces an agent's sessions can be imported into
  • Read app settings settings.readLowReads the app settings.Show the settings, and the tools and rules of every plugin
  • Show panels ui.slotsLowShows views in the listed parts of the window.Draw the settings pageSlots: center

Files

views/keys.ts6.1 KB
// The Keyboard Shortcuts section: every command of the app and of the
// plugins, with its keys. A key is recorded natively (the kernel takes the
// next keystroke before any binding does); a second stroke makes a chord.
// HTM whitespace is significant; keep these templates out of Prettier's HTML formatter.
import { html as htm } from "convergence";
import type { EffectViewContext } from "convergence/effect";
import type { CommandInfo } from "convergence";
import type { SettingsPage } from "../controller.ts";
import { shortcutGroups } from "../model.ts";
import { card, section, column, fill, row } from "./layout.ts";

// A key binding as the kernel's key caps, or "No key".
export function capsOf(label: string | null) {
  if (!label) return htm`<text style="body" size="xs" tone="faint">No key</text>`;
  return htm`<text keys>${label}</text>`;
}

// A recorded key as a keyboard shows it, from the table's labels where
// the command list has it, else as recorded.
function labelOf(commands: readonly CommandInfo[], key: string) {
  for (const command of commands) {
    const at = command.keys.indexOf(key);
    if (at >= 0) return command.labels[at];
  }
  return key;
}

function shortcutRow(ctx: EffectViewContext<object>, actions: SettingsPage["Service"], command: CommandInfo) {
  const state = actions.state;
  const recording = state.recording?.command === command.id ? state.recording : null;
  const id = command.id.replace(/[^a-zA-Z0-9]/g, "-");
  const conflict = command.conflict
    ? htm`
        <div ...${row} gap="small" padding="small">
          <badge variant="warning">Conflict</badge>
          <text style="body" size="xs" tone="muted">${`${command.conflict.label} is used by ${command.conflict.withTitle}`}</text>
          <button id=${`assign-${id}`} label="Assign…" variant="ghost" small onClick=${() => ctx.run(actions.record(command))} />
        </div>`
    : null;
  const keysNow = recording
    ? htm`
        <div ...${row} gap="small" padding="small">
          ${recording.strokes.length ? capsOf(recording.strokes.map((key) => labelOf(state.keys?.commands ?? [], key)).join(" ")) : null}
          <text style="body" size="xs" tone="primary">${recording.waiting ? (recording.strokes.length ? "Press the second key…" : "Press the keys…") : ""}</text>
          ${
            !recording.waiting && recording.strokes.length < 2
              ? htm`<button id=${`chord-${id}`} label="Add a second key" variant="ghost" small onClick=${() => ctx.run(actions.recordMore())} />`
              : null
          }
          ${
            recording.strokes.length && !recording.waiting
              ? htm`<button id=${`save-key-${id}`} label="Save" variant="primary" small onClick=${() => ctx.run(actions.saveRecording())} />`
              : null
          }
          <button id=${`cancel-key-${id}`} label="Cancel" variant="ghost" small onClick=${() => {
            if (actions.stopRecording()) ctx.run(actions.cancelRecord);
          }} />
        </div>`
    : htm`<div ...${row} gap="small" padding="small">${command.labels.length ? command.labels.map((label) => capsOf(label)) : capsOf(null)}</div>`;
  return htm`
      <div key=${command.id} ...${column} gap="small" padding="small" paddingX="medium" paddingY="small">
        <div ...${row} gap="medium" padding="small">
          <div ...${column} ...${fill} padding="small">
            <div ...${row} gap="small" padding="small">
              <text style="body" size="sm">${command.title}</text>
              ${command.source === "user" ? htm`<badge variant="info">Changed</badge>` : null}
            </div>
            <text style="mono" size="xs" tone="faint">${command.when ? `${command.id} · when ${command.when}` : command.id}</text>
          </div>
          ${keysNow}
          ${
            recording
              ? null
              : htm`
              <div ...${row} gap=${2} padding="small">
                <button id=${`record-${id}`} icon="pencil" variant="ghost" small tooltip="Record a key" onClick=${() => ctx.run(actions.record(command))} />
                ${
                  command.source === "user"
                    ? htm`<button id=${`reset-${id}`} icon="rotate-ccw" variant="ghost" small tooltip="Reset to default" onClick=${() => ctx.run(actions.resetKey(command))} />`
                    : null
                }
                ${
                  command.keys.length
                    ? htm`<button id=${`unbind-${id}`} icon="x" variant="ghost" small tooltip="Remove the key" onClick=${() => ctx.run(actions.unbindKey(command))} />`
                    : null
                }
              </div>`
          }
        </div>
        ${conflict}
        ${command.problem ? htm`<text style="body" size="xs" tone="warning">${command.problem}</text>` : null}
      </div>`;
}

export function renderKeys(ctx: EffectViewContext<object>, actions: SettingsPage["Service"]) {
  const state = actions.state;
  const table = state.keys ?? { commands: [], errors: [] };
  const groups = shortcutGroups(table.commands, state.keysQuery);
  return htm`
      <div ...${column} gap="large" padding="small">
        <div ...${row} gap="small" padding="small">
          <div ...${column} ...${fill} padding="small">
            <input id="keys-search" value=${state.keysQuery} placeholder="Search by command, plugin or key"
              onChange=${(e: { value: string }) => {
                state.keysQuery = e.value;
                ctx.update();
              }} />
          </div>
          <button id="keys-open-json" label="Open keybindings.json" icon="file-braces" variant="outline" small
            onClick=${() => ctx.run(actions.openKeybindings())} />
        </div>
        ${
          table.errors.length
            ? htm`<panel>
              ${table.errors.map((error) => htm`<text style="body" size="xs" tone="warning">${error}</text>`)}
            </panel>`
            : null
        }
        ${
          groups.length
            ? groups.map((group) =>
                section(group.title, null, card(group.commands.map((command) => shortcutRow(ctx, actions, command)))),
              )
            : htm`<text style="body" size="xs" tone="muted">No command matches.</text>`
        }
      </div>`;
}

Versions

VersionPublishedPlugin APISizePermissionsStatus
0.2.0latestOct 5, 2026>=2 <324.7 KB7 permissionsListed

Reviews and comments

0 threads · 0 reviews

No comments yet.