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
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
| Version | Published | Plugin API | Size | Permissions | Status |
|---|---|---|---|---|---|
| 0.2.0latest | Oct 5, 2026 | >=2 <3 | 24.7 KB | 7 permissions | Listed |
No comments yet.