feat(ui): overhaul settings and slash command UX (#67819) thanks @BunsDev

Co-authored-by: Val Alexander <68980965+BunsDev@users.noreply.github.com>
This commit is contained in:
Val Alexander
2026-04-16 20:29:11 -05:00
committed by GitHub
parent 42805d26cf
commit 2cfb660a9b
21 changed files with 2564 additions and 63 deletions

View File

@@ -4,6 +4,7 @@ import type {
ChatCommandDefinition,
CommandCategory,
CommandScope,
CommandTier,
} from "./commands-registry.types.js";
import { listThinkingLevels } from "./thinking.js";
@@ -20,6 +21,8 @@ type DefineChatCommandInput = {
textAliases?: string[];
scope?: CommandScope;
category?: CommandCategory;
/** Progressive disclosure tier. Defaults to "standard". */
tier?: CommandTier;
};
export function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition {
@@ -42,6 +45,7 @@ export function defineChatCommand(command: DefineChatCommandInput): ChatCommandD
textAliases: aliases,
scope,
category: command.category,
tier: command.tier,
};
}
@@ -129,6 +133,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Show available commands.",
textAlias: "/help",
category: "status",
tier: "essential",
}),
defineChatCommand({
key: "commands",
@@ -136,6 +141,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "List all slash commands.",
textAlias: "/commands",
category: "status",
tier: "power",
}),
defineChatCommand({
key: "tools",
@@ -152,6 +158,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
},
],
argsMenu: "auto",
tier: "standard",
}),
defineChatCommand({
key: "skill",
@@ -159,6 +166,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Run a skill by name.",
textAlias: "/skill",
category: "tools",
tier: "standard",
args: [
{
name: "name",
@@ -180,6 +188,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Show current status.",
textAlias: "/status",
category: "status",
tier: "essential",
}),
defineChatCommand({
key: "tasks",
@@ -187,6 +196,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "List background tasks for this session.",
textAlias: "/tasks",
category: "status",
tier: "standard",
}),
defineChatCommand({
key: "allowlist",
@@ -195,6 +205,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
acceptsArgs: true,
scope: "text",
category: "management",
tier: "power",
}),
defineChatCommand({
key: "approve",
@@ -203,6 +214,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
textAlias: "/approve",
acceptsArgs: true,
category: "management",
tier: "power",
}),
defineChatCommand({
key: "context",
@@ -211,6 +223,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
textAlias: "/context",
acceptsArgs: true,
category: "status",
tier: "standard",
}),
defineChatCommand({
key: "btw",
@@ -219,6 +232,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
textAlias: "/btw",
acceptsArgs: true,
category: "tools",
tier: "standard",
}),
defineChatCommand({
key: "export-session",
@@ -227,6 +241,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
textAliases: ["/export-session", "/export"],
acceptsArgs: true,
category: "status",
tier: "essential",
args: [
{
name: "path",
@@ -242,6 +257,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Control text-to-speech (TTS).",
textAlias: "/tts",
category: "media",
tier: "standard",
args: [
{
name: "action",
@@ -285,6 +301,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Show your sender id.",
textAlias: "/whoami",
category: "status",
tier: "power",
}),
defineChatCommand({
key: "session",
@@ -292,6 +309,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Manage session-level settings (for example /session idle).",
textAlias: "/session",
category: "session",
tier: "power",
args: [
{
name: "action",
@@ -314,6 +332,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "List, kill, log, spawn, or steer subagent runs for this session.",
textAlias: "/subagents",
category: "management",
tier: "standard",
args: [
{
name: "action",
@@ -341,6 +360,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Manage ACP sessions and runtime options.",
textAlias: "/acp",
category: "management",
tier: "power",
args: [
{
name: "action",
@@ -382,6 +402,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
"Bind this thread (Discord) or topic/conversation (Telegram) to a session target.",
textAlias: "/focus",
category: "management",
tier: "power",
args: [
{
name: "target",
@@ -397,6 +418,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Remove the current thread (Discord) or topic/conversation (Telegram) binding.",
textAlias: "/unfocus",
category: "management",
tier: "power",
}),
defineChatCommand({
key: "agents",
@@ -404,6 +426,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "List thread-bound agents for this session.",
textAlias: "/agents",
category: "management",
tier: "standard",
}),
defineChatCommand({
key: "kill",
@@ -411,6 +434,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Kill a running subagent (or all).",
textAlias: "/kill",
category: "management",
tier: "standard",
args: [
{
name: "target",
@@ -426,6 +450,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Send guidance to a running subagent.",
textAlias: "/steer",
category: "management",
tier: "standard",
args: [
{
name: "target",
@@ -446,6 +471,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Show or set config values.",
textAlias: "/config",
category: "management",
tier: "power",
args: [
{
name: "action",
@@ -474,6 +500,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Show or set OpenClaw MCP servers.",
textAlias: "/mcp",
category: "management",
tier: "power",
args: [
{
name: "action",
@@ -502,6 +529,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "List, show, enable, or disable plugins.",
textAliases: ["/plugins", "/plugin"],
category: "management",
tier: "power",
args: [
{
name: "action",
@@ -524,6 +552,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Set runtime debug overrides.",
textAlias: "/debug",
category: "management",
tier: "power",
args: [
{
name: "action",
@@ -552,6 +581,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Usage footer or cost summary.",
textAlias: "/usage",
category: "options",
tier: "standard",
args: [
{
name: "mode",
@@ -568,6 +598,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Stop the current run.",
textAlias: "/stop",
category: "session",
tier: "essential",
}),
defineChatCommand({
key: "restart",
@@ -575,6 +606,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Restart OpenClaw.",
textAlias: "/restart",
category: "tools",
tier: "power",
}),
defineChatCommand({
key: "activation",
@@ -582,6 +614,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Set group activation mode.",
textAlias: "/activation",
category: "management",
tier: "power",
args: [
{
name: "mode",
@@ -598,6 +631,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Set send policy.",
textAlias: "/send",
category: "management",
tier: "power",
args: [
{
name: "mode",
@@ -615,6 +649,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
textAlias: "/reset",
acceptsArgs: true,
category: "session",
tier: "essential",
}),
defineChatCommand({
key: "new",
@@ -623,6 +658,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
textAlias: "/new",
acceptsArgs: true,
category: "session",
tier: "essential",
}),
defineChatCommand({
key: "compact",
@@ -630,6 +666,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Compact the session context.",
textAlias: "/compact",
category: "session",
tier: "essential",
args: [
{
name: "instructions",
@@ -645,6 +682,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Set thinking level.",
textAlias: "/think",
category: "options",
tier: "essential",
args: [
{
name: "level",
@@ -661,6 +699,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Toggle verbose mode.",
textAlias: "/verbose",
category: "options",
tier: "standard",
args: [
{
name: "mode",
@@ -677,6 +716,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Toggle plugin trace lines.",
textAlias: "/trace",
category: "options",
tier: "power",
args: [
{
name: "mode",
@@ -693,6 +733,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Toggle fast mode.",
textAlias: "/fast",
category: "options",
tier: "standard",
args: [
{
name: "mode",
@@ -709,6 +750,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Toggle reasoning visibility.",
textAlias: "/reasoning",
category: "options",
tier: "standard",
args: [
{
name: "mode",
@@ -725,6 +767,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Toggle elevated mode.",
textAlias: "/elevated",
category: "options",
tier: "power",
args: [
{
name: "mode",
@@ -741,6 +784,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Set exec defaults for this session.",
textAlias: "/exec",
category: "options",
tier: "power",
args: [
{
name: "host",
@@ -775,6 +819,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Show or set the model.",
textAlias: "/model",
category: "options",
tier: "essential",
args: [
{
name: "model",
@@ -788,6 +833,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
nativeName: "models",
description: "List model providers or provider models.",
textAlias: "/models",
tier: "standard",
argsParsing: "none",
acceptsArgs: true,
category: "options",
@@ -798,6 +844,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
description: "Adjust queue settings.",
textAlias: "/queue",
category: "options",
tier: "power",
args: [
{
name: "mode",
@@ -831,6 +878,7 @@ export function buildBuiltinChatCommands(): ChatCommandDefinition[] {
textAlias: "/bash",
scope: "text",
category: "tools",
tier: "power",
args: [
{
name: "command",

View File

@@ -50,6 +50,7 @@ export type {
CommandDetection,
CommandNormalizeOptions,
CommandScope,
CommandTier,
NativeCommandSpec,
ShouldHandleTextCommandsParams,
} from "./commands-registry.types.js";

View File

@@ -5,6 +5,14 @@ export type { CommandArgValue, CommandArgValues, CommandArgs } from "./commands-
export type CommandScope = "text" | "native" | "both";
/**
* Controls progressive disclosure of commands in the UI.
* - "essential": Always visible (~10 core commands)
* - "standard": Shown on expand / "Show more" (~15 commands)
* - "power": Only surfaced via search or explicit filter (~15 commands)
*/
export type CommandTier = "essential" | "standard" | "power";
export type CommandCategory =
| "session"
| "options"
@@ -57,6 +65,8 @@ export type ChatCommandDefinition = {
argsMenu?: CommandArgMenuSpec | "auto";
scope: CommandScope;
category?: CommandCategory;
/** Progressive disclosure tier. Defaults to "standard" when omitted. */
tier?: CommandTier;
};
export type NativeCommandSpec = {

View File

@@ -1,4 +1,4 @@
import { isPlainObject } from "../utils.js";
import { isPlainObject } from "../infra/plain-object.js";
import { isBlockedObjectKey } from "./prototype-keys.js";
type PlainObject = Record<string, unknown>;

View File

@@ -4,6 +4,8 @@
@import "./styles/components.css";
@import "./styles/chat.css";
@import "./styles/config.css";
@import "./styles/config-quick.css";
@import "./styles/cron-quick-create.css";
@import "./styles/usage.css";
@import "./styles/dreams.css";
@import "@create-markdown/preview/themes/system.css";

View File

@@ -766,6 +766,31 @@
flex-shrink: 0;
}
.slash-menu-show-more {
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
width: 100%;
padding: 8px 10px;
margin-top: 4px;
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
background: none;
border: none;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
cursor: pointer;
transition:
background var(--duration-fast) ease,
color var(--duration-fast) ease;
}
.slash-menu-show-more:hover {
background: color-mix(in srgb, var(--accent) 8%, transparent);
color: var(--accent-hover);
}
.slash-menu-footer {
display: flex;
gap: 10px;

View File

@@ -0,0 +1,586 @@
/* ── Quick Settings ── */
.qs-container {
width: 100%;
max-width: none;
margin: 0;
padding: 32px 0 56px;
}
.qs-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
}
.qs-header__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.35rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
color: var(--text-strong);
}
.qs-header__title svg {
width: 22px;
height: 22px;
opacity: 0.5;
color: currentColor;
stroke: currentColor;
fill: none;
}
.qs-header__title svg * {
stroke: currentColor;
fill: none;
}
/* ── Grid ── */
.qs-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
align-items: stretch;
gap: 14px;
}
/* ── Card ── */
.qs-card {
min-width: 0;
height: 100%;
background: var(--card);
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-lg);
overflow: hidden;
transition:
border-color var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out);
}
.qs-card:hover {
border-color: color-mix(in srgb, var(--border-strong) 70%, transparent);
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.15);
}
.qs-card--span-all {
grid-column: 1 / -1;
}
.qs-card__header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: color-mix(in srgb, var(--bg-elevated) 60%, var(--card) 40%);
}
.qs-card__header-left {
display: flex;
align-items: center;
gap: 8px;
}
.qs-card__icon {
display: flex;
align-items: center;
justify-content: center;
width: 14px;
height: 14px;
flex: 0 0 auto;
background: transparent;
color: inherit;
}
.qs-card__icon svg {
width: 14px;
height: 14px;
color: inherit;
stroke: currentColor;
fill: none;
}
.qs-card__icon svg * {
stroke: currentColor;
fill: none;
}
.qs-card__title {
font-size: 0.8125rem;
font-weight: 650;
letter-spacing: -0.01em;
margin: 0;
color: var(--text-strong);
}
.qs-card__body {
padding: 0;
}
/* ── Row ── */
.qs-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 9px 16px;
min-height: 38px;
gap: 10px;
}
.qs-row + .qs-row {
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.qs-row__label {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
font-weight: 450;
color: var(--text);
white-space: nowrap;
}
.qs-row__value {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.8125rem;
color: var(--muted);
}
.qs-row__value--action {
cursor: pointer;
background: none;
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
padding: 4px 8px;
border-radius: var(--radius-sm);
transition:
background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
}
.qs-row__value--action:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
}
.qs-row__value--action code {
font-family: var(--mono);
font-size: 0.75rem;
color: var(--accent);
}
.qs-row__chevron svg {
width: 12px;
height: 12px;
opacity: 0.35;
}
/* ── Segmented control ── */
.qs-segmented {
display: flex;
gap: 2px;
background: color-mix(in srgb, var(--bg) 80%, var(--bg-elevated) 20%);
border: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
border-radius: var(--radius-md);
padding: 2px;
}
.qs-segmented__btn {
font-size: 0.75rem;
font-weight: 550;
padding: 4px 12px;
border: none;
border-radius: calc(var(--radius-md) - 3px);
background: transparent;
color: var(--muted);
cursor: pointer;
transition: all var(--duration-normal) var(--ease-out);
white-space: nowrap;
position: relative;
}
.qs-segmented__btn--compact {
padding: 5px 10px;
}
.qs-segmented__btn:hover {
color: var(--text);
}
.qs-segmented__btn--active {
background: var(--bg-elevated);
color: var(--text-strong);
box-shadow: var(--shadow-sm);
}
/* ── Toggle switch ── */
.qs-toggle {
display: flex;
align-items: center;
gap: 10px;
cursor: pointer;
}
.qs-toggle input {
display: none;
}
.qs-toggle__track {
position: relative;
width: 38px;
height: 22px;
background: color-mix(in srgb, var(--bg) 70%, var(--border) 30%);
border: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
border-radius: 11px;
transition:
background var(--duration-normal) var(--ease-out),
border-color var(--duration-normal) var(--ease-out);
flex-shrink: 0;
}
.qs-toggle__track::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
background: var(--muted);
border-radius: 50%;
transition:
transform var(--duration-normal) var(--ease-spring),
background var(--duration-normal) var(--ease-out);
}
.qs-toggle input:checked + .qs-toggle__track {
background: var(--accent);
border-color: var(--accent);
}
.qs-toggle input:checked + .qs-toggle__track::after {
transform: translateX(16px);
background: #fff;
}
.qs-toggle__hint {
font-size: 0.75rem;
color: var(--muted);
}
/* ── Badge ── */
.qs-badge {
display: inline-flex;
align-items: center;
font-size: 0.6875rem;
font-weight: 600;
padding: 3px 9px;
border-radius: var(--radius-full);
background: color-mix(in srgb, var(--bg-elevated) 80%, var(--border) 20%);
color: var(--muted);
text-transform: capitalize;
letter-spacing: 0.01em;
}
.qs-badge--ok {
background: var(--ok-subtle);
color: var(--ok);
}
.qs-badge--warn {
background: var(--warn-subtle);
color: var(--warn);
}
/* ── Status dot ── */
.qs-status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--muted);
flex-shrink: 0;
transition: background var(--duration-normal) var(--ease-out);
}
.qs-status-dot--ok {
background: var(--ok);
box-shadow: 0 0 6px var(--ok-subtle);
}
/* ── Masked value ── */
.qs-masked {
font-family: var(--mono);
font-size: 0.75rem;
letter-spacing: 1.5px;
color: var(--muted);
}
/* ── Link button ── */
.qs-link-btn {
font-size: 0.75rem;
font-weight: 600;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
padding: 2px 4px;
border-radius: var(--radius-sm);
transition:
opacity var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out);
white-space: nowrap;
}
.qs-link-btn:hover {
opacity: 0.85;
background: var(--accent-subtle);
}
/* ── Empty state ── */
.qs-empty {
padding: 14px 16px;
font-size: 0.8125rem;
color: var(--muted);
}
/* ── Footer ── */
.qs-footer {
margin-top: 24px;
padding-top: 16px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.qs-footer__row {
display: flex;
align-items: center;
gap: 5px;
font-size: 0.75rem;
color: var(--muted);
}
/* ── Config Accordion Nav (Advanced Settings) ── */
.config-accordion-nav {
margin-bottom: 20px;
}
.config-accordion-nav__back {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.8125rem;
font-weight: 600;
color: var(--accent);
background: none;
border: none;
cursor: pointer;
padding: 8px 12px;
margin-bottom: 8px;
border-radius: var(--radius-md);
transition:
opacity var(--duration-fast) var(--ease-out),
background var(--duration-fast) var(--ease-out);
}
.config-accordion-nav__back:hover {
opacity: 0.85;
background: var(--accent-subtle);
}
.config-accordion-group {
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: var(--radius-md);
margin-bottom: 4px;
overflow: hidden;
transition: border-color var(--duration-fast) var(--ease-out);
}
.config-accordion-group:hover {
border-color: var(--border);
}
.config-accordion-group__header {
display: flex;
align-items: center;
gap: 10px;
width: 100%;
padding: 12px 16px;
font-size: 0.8125rem;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text-strong);
background: color-mix(in srgb, var(--bg-elevated) 50%, var(--card) 50%);
border: none;
cursor: pointer;
transition: background var(--duration-fast) var(--ease-out);
}
.config-accordion-group__header:hover {
background: var(--bg-hover);
}
.config-accordion-group__header--active {
background: var(--bg-hover);
}
.config-accordion-group__icon {
display: flex;
align-items: center;
color: var(--accent);
opacity: 0.7;
}
.config-accordion-group__icon svg {
width: 16px;
height: 16px;
}
.config-accordion-group__chevron {
margin-left: auto;
opacity: 0.35;
transition: transform var(--duration-normal) var(--ease-out);
}
.config-accordion-group__chevron--open {
transform: rotate(180deg);
}
.config-accordion-group__items {
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
padding: 4px 0;
background: var(--card);
}
.config-accordion-group__item {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 9px 16px 9px 40px;
font-size: 0.8125rem;
font-weight: 450;
color: var(--muted);
background: none;
border: none;
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.config-accordion-group__item:hover {
background: var(--bg-hover);
color: var(--text);
}
.config-accordion-group__item--active {
color: var(--accent);
font-weight: 600;
}
.config-accordion-group__item-icon {
display: flex;
align-items: center;
opacity: 0.45;
}
.config-accordion-group__item-icon svg {
width: 14px;
height: 14px;
}
/* ── Presets Grid ── */
.qs-presets-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 8px;
padding: 12px 16px !important;
}
.qs-preset {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
padding: 12px;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: var(--radius-md);
background: var(--card);
cursor: pointer;
transition:
border-color var(--duration-normal) var(--ease-out),
background var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out);
text-align: left;
}
.qs-preset:hover {
border-color: color-mix(in srgb, var(--accent) 40%, var(--border) 60%);
background: color-mix(in srgb, var(--accent-subtle) 40%, var(--card) 60%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 10%, transparent);
}
.qs-preset--active {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border) 50%);
background: color-mix(in srgb, var(--accent-subtle) 60%, var(--card) 40%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 15%, transparent);
}
.qs-preset--active:hover {
background: color-mix(in srgb, var(--accent-subtle) 80%, var(--card) 20%);
}
.qs-preset__icon {
font-size: 1.35rem;
line-height: 1;
}
.qs-preset__label {
font-size: 0.8125rem;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text-strong);
}
.qs-preset__desc {
font-size: 0.6875rem;
line-height: 1.4;
color: var(--muted);
}
@media (max-width: 480px) {
.qs-container {
padding: 20px 0 40px;
}
.qs-presets-grid {
grid-template-columns: 1fr;
}
.qs-header {
align-items: flex-start;
}
.qs-header__title {
font-size: 1.15rem;
}
}

View File

@@ -0,0 +1,378 @@
/* ── Cron Quick Create Wizard ── */
.cqc-container {
max-width: 600px;
margin: 0 auto;
padding: 32px 20px 56px;
}
.cqc-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 28px;
}
.cqc-header__title {
display: flex;
align-items: center;
gap: 10px;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
color: var(--text-strong);
}
.cqc-header__title svg {
width: 20px;
height: 20px;
color: var(--accent);
}
.cqc-header__close {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
border-radius: var(--radius-md);
background: transparent;
color: var(--muted);
cursor: pointer;
transition:
background var(--duration-fast) var(--ease-out),
border-color var(--duration-fast) var(--ease-out),
color var(--duration-fast) var(--ease-out);
}
.cqc-header__close:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
color: var(--text);
}
.cqc-header__close svg {
width: 14px;
height: 14px;
}
/* ── Step Indicator ── */
.cqc-steps {
display: flex;
align-items: center;
justify-content: center;
gap: 0;
margin-bottom: 36px;
}
.cqc-step {
display: flex;
align-items: center;
gap: 8px;
}
.cqc-step__dot {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border-radius: 50%;
font-size: 0.6875rem;
font-weight: 700;
background: color-mix(in srgb, var(--bg-elevated) 80%, var(--border) 20%);
color: var(--muted);
flex-shrink: 0;
transition:
background var(--duration-slow) var(--ease-out),
color var(--duration-slow) var(--ease-out),
box-shadow var(--duration-slow) var(--ease-out);
}
.cqc-step--active .cqc-step__dot {
background: var(--accent);
color: var(--primary-foreground);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.cqc-step--done .cqc-step__dot {
background: var(--ok-subtle);
color: var(--ok);
}
.cqc-step__label {
font-size: 0.75rem;
font-weight: 600;
color: var(--muted);
transition: color var(--duration-slow) var(--ease-out);
}
.cqc-step--active .cqc-step__label {
color: var(--text-strong);
}
.cqc-step--done .cqc-step__label {
color: var(--ok);
}
.cqc-step__line {
width: 48px;
height: 2px;
background: color-mix(in srgb, var(--border) 60%, transparent);
margin: 0 10px;
border-radius: 1px;
transition: background var(--duration-slow) var(--ease-out);
}
.cqc-step__line--done {
background: color-mix(in srgb, var(--ok) 40%, transparent);
}
.cqc-step__line--active {
background: color-mix(in srgb, var(--accent) 50%, transparent);
}
/* ── Body ── */
.cqc-body {
margin-bottom: 28px;
}
.cqc-body__heading {
font-size: 1.05rem;
font-weight: 700;
letter-spacing: -0.02em;
margin: 0 0 4px;
color: var(--text-strong);
}
.cqc-body__hint {
font-size: 0.8125rem;
margin: 0 0 20px;
color: var(--muted);
line-height: 1.5;
}
/* ── Form elements ── */
.cqc-textarea {
width: 100%;
min-height: 110px;
padding: 14px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-md);
background: var(--card);
color: var(--text);
font: inherit;
font-size: 0.875rem;
line-height: 1.6;
resize: vertical;
box-sizing: border-box;
transition:
border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.cqc-textarea:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.cqc-textarea::placeholder {
color: var(--muted);
opacity: 0.7;
}
.cqc-field {
margin-top: 16px;
}
.cqc-field__label {
display: block;
font-size: 0.75rem;
font-weight: 600;
color: var(--muted);
margin-bottom: 6px;
letter-spacing: 0.01em;
}
.cqc-input {
width: 100%;
padding: 10px 14px;
border: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-md);
background: var(--card);
color: var(--text);
font: inherit;
font-size: 0.8125rem;
box-sizing: border-box;
transition:
border-color var(--duration-fast) var(--ease-out),
box-shadow var(--duration-fast) var(--ease-out);
}
.cqc-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-subtle);
}
.cqc-input::placeholder {
color: var(--muted);
opacity: 0.7;
}
/* ── Schedule Preset Grid ── */
.cqc-preset-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.cqc-preset-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding: 18px 10px;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: var(--radius-md);
background: var(--card);
cursor: pointer;
transition:
border-color var(--duration-normal) var(--ease-out),
background var(--duration-normal) var(--ease-out),
box-shadow var(--duration-normal) var(--ease-out),
transform var(--duration-fast) var(--ease-out);
text-align: center;
}
.cqc-preset-card:hover {
border-color: color-mix(in srgb, var(--accent) 35%, var(--border) 65%);
background: color-mix(in srgb, var(--accent-subtle) 30%, var(--card) 70%);
transform: translateY(-1px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.12);
}
.cqc-preset-card--active {
border-color: color-mix(in srgb, var(--accent) 50%, var(--border) 50%);
background: color-mix(in srgb, var(--accent-subtle) 50%, var(--card) 50%);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 12%, transparent);
}
.cqc-preset-card--active:hover {
background: color-mix(in srgb, var(--accent-subtle) 70%, var(--card) 30%);
}
.cqc-preset-card__icon {
font-size: 1.5rem;
line-height: 1;
}
.cqc-preset-card__label {
font-size: 0.8125rem;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text-strong);
}
.cqc-preset-card__desc {
font-size: 0.6875rem;
color: var(--muted);
line-height: 1.3;
}
/* ── Delivery Radio Cards ── */
.cqc-delivery-options {
display: flex;
flex-direction: column;
gap: 8px;
}
.cqc-radio-card {
display: flex;
align-items: center;
gap: 14px;
padding: 16px 18px;
border: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
border-radius: var(--radius-md);
background: var(--card);
cursor: pointer;
transition:
border-color var(--duration-normal) var(--ease-out),
background var(--duration-normal) var(--ease-out);
}
.cqc-radio-card:hover {
border-color: color-mix(in srgb, var(--accent) 30%, var(--border) 70%);
background: color-mix(in srgb, var(--accent-subtle) 20%, var(--card) 80%);
}
.cqc-radio-card--active {
border-color: color-mix(in srgb, var(--accent) 45%, var(--border) 55%);
background: color-mix(in srgb, var(--accent-subtle) 35%, var(--card) 65%);
}
.cqc-radio-card input[type="radio"] {
accent-color: var(--accent);
margin: 0;
width: 16px;
height: 16px;
}
.cqc-radio-card__label {
font-size: 0.875rem;
font-weight: 650;
letter-spacing: -0.01em;
color: var(--text-strong);
}
.cqc-radio-card__desc {
font-size: 0.75rem;
margin-left: auto;
color: var(--muted);
}
/* ── Actions ── */
.cqc-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
padding-top: 20px;
border-top: 1px solid color-mix(in srgb, var(--border) 50%, transparent);
}
.cqc-actions .btn.primary {
display: flex;
align-items: center;
gap: 4px;
}
.cqc-actions .btn.primary svg {
width: 14px;
height: 14px;
}
/* ── Responsive ── */
@media (max-width: 480px) {
.cqc-container {
padding: 20px 14px 40px;
}
.cqc-preset-grid {
grid-template-columns: repeat(2, 1fr);
}
.cqc-step__line {
width: 32px;
margin: 0 6px;
}
}

View File

@@ -1,4 +1,5 @@
import { html, nothing } from "lit";
import { applyMergePatch } from "../../../src/config/merge-patch.ts";
import {
buildAgentMainSessionKey,
parseAgentSessionKey,
@@ -7,6 +8,7 @@ import {
import { t } from "../i18n/index.ts";
import { getSafeLocalStorage } from "../local-storage.ts";
import { refreshChatAvatar } from "./app-chat.ts";
import { DEFAULT_CRON_FORM } from "./app-defaults.ts";
import { renderUsageTab } from "./app-render-usage-tab.ts";
import {
renderChatControls,
@@ -45,6 +47,7 @@ import {
updateConfigFormValue,
removeConfigFormValue,
} from "./controllers/config.ts";
import { cloneConfigObject, serializeConfigForm } from "./controllers/config/form-utils.ts";
import {
loadCronJobsPage,
loadCronRuns,
@@ -129,7 +132,18 @@ import {
} from "./views/agents-utils.ts";
import { renderChat } from "./views/chat.ts";
import { renderCommandPalette } from "./views/command-palette.ts";
import { getPresetById, type ConfigPresetId } from "./views/config-presets.ts";
import {
renderQuickSettings,
type QuickSettingsChannel,
type QuickSettingsApiKey,
} from "./views/config-quick.ts";
import { renderConfig, type ConfigProps } from "./views/config.ts";
import {
renderCronQuickCreate,
createDefaultDraft,
draftToCronFormPatch,
} from "./views/cron-quick-create.ts";
import { renderDreaming } from "./views/dreaming.ts";
import { renderExecApprovalPrompt } from "./views/exec-approval.ts";
import { renderGatewayUrlConfirmation } from "./views/gateway-url-confirmation.ts";
@@ -350,6 +364,8 @@ type ConfigTabOverrides = Pick<
| "includeSections"
| "excludeSections"
| "includeVirtualSections"
| "settingsLayout"
| "onBackToQuick"
>
>;
@@ -398,6 +414,239 @@ function resolveAssistantAvatarUrl(state: AppViewState): string | undefined {
return identity?.avatarUrl;
}
// ── Quick Settings data extraction helpers ──
const KNOWN_CHANNEL_IDS = [
{ id: "telegram", label: "Telegram" },
{ id: "discord", label: "Discord" },
{ id: "slack", label: "Slack" },
{ id: "whatsapp", label: "WhatsApp" },
{ id: "signal", label: "Signal" },
{ id: "imessage", label: "iMessage" },
] as const;
const KNOWN_PROVIDER_KEYS = [
{ provider: "anthropic", label: "Anthropic", envKey: "ANTHROPIC_API_KEY" },
{ provider: "openai", label: "OpenAI", envKey: "OPENAI_API_KEY" },
{ provider: "google", label: "Google", envKey: "GOOGLE_API_KEY" },
{ provider: "openrouter", label: "OpenRouter", envKey: "OPENROUTER_API_KEY" },
] as const;
function formatQuickSettingsLabel(id: string): string {
const trimmed = id.trim();
if (!trimmed) {
return "Unknown";
}
return trimmed
.split(/[-_]+/)
.filter(Boolean)
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join(" ");
}
function extractQuickSettingsChannels(state: AppViewState): QuickSettingsChannel[] {
const config = state.configForm ?? state.configSnapshot?.config;
if (!config || typeof config !== "object") {
return [];
}
const channelsConfig =
"channels" in config && config.channels && typeof config.channels === "object"
? (config.channels as Record<string, unknown>)
: {};
const configuredIds = Object.keys(channelsConfig).filter((id) => id.trim().length > 0);
const channelIds =
configuredIds.length > 0
? configuredIds.toSorted((a, b) => a.localeCompare(b))
: KNOWN_CHANNEL_IDS.map(({ id }) => id);
const knownLabels = new Map<string, string>(
KNOWN_CHANNEL_IDS.map(({ id, label }) => [id, label]),
);
const channels: QuickSettingsChannel[] = [];
for (const id of channelIds) {
const channelConfig = channelsConfig[id];
const hasConfig =
channelConfig != null &&
typeof channelConfig === "object" &&
Object.keys(channelConfig).length > 0;
channels.push({
id,
label: knownLabels.get(id) ?? formatQuickSettingsLabel(id),
connected: hasConfig,
detail: hasConfig ? "Configured" : undefined,
});
}
return channels;
}
function extractQuickSettingsApiKeys(state: AppViewState): QuickSettingsApiKey[] {
const config = state.configForm ?? state.configSnapshot?.config;
const env = config && typeof config === "object" ? config.env : null;
const envObj = env && typeof env === "object" ? (env as Record<string, unknown>) : {};
const envVars =
envObj.vars && typeof envObj.vars === "object" ? (envObj.vars as Record<string, unknown>) : {};
return KNOWN_PROVIDER_KEYS.map(({ provider, label, envKey }) => {
const value = typeof envVars[envKey] === "string" ? envVars[envKey] : envObj[envKey];
const isSet = typeof value === "string" && value.trim().length > 0;
const masked = isSet ? `••••${value.slice(-4)}` : undefined;
return { provider, label, masked, isSet };
});
}
function extractMcpServerCount(state: AppViewState): number {
const config = state.configForm ?? state.configSnapshot?.config;
if (!config || typeof config !== "object") {
return 0;
}
const mcp = config.mcp;
if (!mcp || typeof mcp !== "object") {
return 0;
}
const servers =
"servers" in mcp && mcp.servers && typeof mcp.servers === "object"
? (mcp.servers as Record<string, unknown>)
: {};
return Object.keys(servers).length;
}
function extractQuickSettingsSecurity(state: AppViewState): {
gatewayAuth: string;
execPolicy: string;
deviceAuth: boolean;
} {
const config = state.configForm ?? state.configSnapshot?.config;
if (!config || typeof config !== "object") {
return { gatewayAuth: "unknown", execPolicy: "unknown", deviceAuth: false };
}
const cfg = config;
const gateway =
"gateway" in cfg && cfg.gateway && typeof cfg.gateway === "object"
? (cfg.gateway as Record<string, unknown>)
: null;
const auth =
gateway && "auth" in gateway && gateway.auth && typeof gateway.auth === "object"
? (gateway.auth as Record<string, unknown>)
: null;
let gatewayAuth = "unknown";
if (auth) {
const mode = typeof auth.mode === "string" ? auth.mode.trim() : "";
if (mode) {
gatewayAuth = mode;
} else if (auth.password) {
gatewayAuth = "password";
} else if (auth.token) {
gatewayAuth = "token";
} else if (auth.trustedProxy) {
gatewayAuth = "trusted-proxy";
} else {
gatewayAuth = "none";
}
}
const agents = cfg.agents;
let execPolicy = "allowlist";
if (agents && typeof agents === "object") {
const defaults = (agents as Record<string, unknown>).defaults;
if (defaults && typeof defaults === "object") {
const exec = (defaults as Record<string, unknown>).exec;
if (exec && typeof exec === "object") {
const security = (exec as Record<string, unknown>).security;
if (typeof security === "string") {
execPolicy = security;
}
}
}
}
let deviceAuth = true;
if (gateway) {
const controlUi =
"controlUi" in gateway && gateway.controlUi && typeof gateway.controlUi === "object"
? (gateway.controlUi as Record<string, unknown>)
: null;
if (controlUi?.dangerouslyDisableDeviceAuth === true) {
deviceAuth = false;
}
}
return { gatewayAuth, execPolicy, deviceAuth };
}
function resolveQuickSettingsSessionRow(state: AppViewState) {
return state.sessionsResult?.sessions?.find((row) => row.key === state.sessionKey);
}
async function applyQuickSettingsPreset(state: AppViewState, presetId: ConfigPresetId) {
if (!state.client || !state.connected) {
return;
}
const preset = getPresetById(presetId);
if (!preset) {
return;
}
state.configApplying = true;
state.lastError = null;
try {
if (!state.configSnapshot?.hash) {
await loadConfig(state);
}
const baseHash = state.configSnapshot?.hash?.trim();
if (!baseHash) {
throw new Error("Config base hash unavailable. Reload config and retry.");
}
const baseConfig = cloneConfigObject(state.configForm ?? state.configSnapshot?.config ?? {});
const merged = applyMergePatch(baseConfig, preset.patch) as Record<string, unknown>;
await state.client.request("config.patch", { raw: serializeConfigForm(merged), baseHash });
await loadConfig(state);
} catch (err) {
state.lastError = `Failed to apply preset: ${String(err)}`;
} finally {
state.configApplying = false;
}
}
function renderCronQuickCreateForTab(
state: AppViewState,
requestHostUpdate: (() => void) | undefined,
) {
return renderCronQuickCreate({
open: state.cronQuickCreateOpen,
step: state.cronQuickCreateStep,
draft: state.cronQuickCreateDraft ?? createDefaultDraft(),
onDraftChange: (patch) => {
state.cronQuickCreateDraft = {
...(state.cronQuickCreateDraft ?? createDefaultDraft()),
...patch,
};
requestHostUpdate?.();
},
onStepChange: (step) => {
state.cronQuickCreateStep = step;
requestHostUpdate?.();
},
onCreate: () => {
const draft = state.cronQuickCreateDraft ?? createDefaultDraft();
const formPatch = draftToCronFormPatch(draft);
state.cronEditingJobId = null;
state.cronForm = { ...DEFAULT_CRON_FORM, ...formPatch } as typeof state.cronForm;
requestHostUpdate?.();
void (async () => {
await addCronJob(state);
if (state.cronError || hasCronFormErrors(state.cronFieldErrors)) {
requestHostUpdate?.();
return;
}
state.cronQuickCreateOpen = false;
state.cronQuickCreateStep = "what";
state.cronQuickCreateDraft = null;
requestHostUpdate?.();
})();
},
onCancel: () => {
state.cronQuickCreateOpen = false;
state.cronQuickCreateStep = "what";
state.cronQuickCreateDraft = null;
requestHostUpdate?.();
},
});
}
export function renderApp(state: AppViewState) {
const updatableState = state as AppViewState & { requestUpdate?: () => void };
const requestHostUpdate =
@@ -671,7 +920,106 @@ export function renderApp(state: AppViewState) {
);
const renderConfigTabForActiveTab = () => {
switch (state.tab) {
case "config":
case "config": {
// Quick Settings mode — opinionated card layout
if (state.configSettingsMode === "quick") {
const configObj = state.configForm ?? state.configSnapshot?.config ?? {};
const agentsDefaults = ((configObj.agents as Record<string, unknown> | undefined)
?.defaults ?? {}) as Record<string, unknown>;
const activeSession = resolveQuickSettingsSessionRow(state);
const currentModel =
typeof activeSession?.model === "string"
? activeSession.model
: typeof agentsDefaults.model === "string"
? agentsDefaults.model
: "default";
const thinkingLevel =
typeof activeSession?.thinkingLevel === "string"
? activeSession.thinkingLevel
: typeof agentsDefaults.thinkingLevel === "string"
? agentsDefaults.thinkingLevel
: "off";
const fastMode =
typeof activeSession?.fastMode === "boolean"
? activeSession.fastMode
: agentsDefaults.fastMode === true;
return renderQuickSettings({
currentModel,
thinkingLevel,
fastMode,
onModelChange: () => {
state.configSettingsMode = "advanced";
state.tab = "aiAgents" as import("./navigation.ts").Tab;
state.aiAgentsActiveSection = "models";
requestHostUpdate?.();
},
onThinkingChange: (level) => {
void patchSession(state, state.sessionKey, { thinkingLevel: level }).then(() =>
requestHostUpdate?.(),
);
},
onFastModeToggle: () => {
void patchSession(state, state.sessionKey, { fastMode: !fastMode }).then(() =>
requestHostUpdate?.(),
);
},
channels: extractQuickSettingsChannels(state),
onChannelConfigure: () => {
state.tab = "communications" as import("./navigation.ts").Tab;
state.communicationsActiveSection = "channels";
requestHostUpdate?.();
},
apiKeys: extractQuickSettingsApiKeys(state),
onApiKeyChange: () => {
state.configSettingsMode = "advanced";
state.configActiveSection = "env";
requestHostUpdate?.();
},
automation: {
cronJobCount: state.cronJobs?.length ?? 0,
skillCount: state.skillsReport?.skills?.length ?? 0,
mcpServerCount: extractMcpServerCount(state),
},
onManageCron: () => {
state.tab = "cron" as import("./navigation.ts").Tab;
requestHostUpdate?.();
},
onBrowseSkills: () => {
state.tab = "skills" as import("./navigation.ts").Tab;
requestHostUpdate?.();
},
onConfigureMcp: () => {
state.tab = "infrastructure" as import("./navigation.ts").Tab;
state.infrastructureActiveSection = "mcp";
requestHostUpdate?.();
},
security: extractQuickSettingsSecurity(state),
onSecurityConfigure: () => {
state.configSettingsMode = "advanced";
state.configActiveSection = "auth";
requestHostUpdate?.();
},
theme: state.theme,
themeMode: state.themeMode,
borderRadius: state.settings.borderRadius,
setTheme: (theme, context) => state.setTheme(theme, context),
setThemeMode: (mode, context) => state.setThemeMode(mode, context),
setBorderRadius: (value) => state.setBorderRadius(value),
configObject: configObj,
onApplyPreset: (presetId) => {
void applyQuickSettingsPreset(state, presetId).then(() => requestHostUpdate?.());
},
onAdvancedSettings: () => {
state.configSettingsMode = "advanced";
requestHostUpdate?.();
},
connected: state.connected,
gatewayUrl: state.settings.gatewayUrl,
assistantName: state.assistantName,
version: state.hello?.server?.version ?? "",
});
}
// Advanced mode — full config form with accordion groups
return renderConfigTab({
formMode: state.configFormMode,
searchQuery: state.configSearchQuery,
@@ -685,6 +1033,11 @@ export function renderApp(state: AppViewState) {
},
onSubsectionChange: (section) => (state.configActiveSubsection = section),
showModeToggle: true,
settingsLayout: "accordion",
onBackToQuick: () => {
state.configSettingsMode = "quick";
requestHostUpdate?.();
},
excludeSections: [
...COMMUNICATION_SECTION_KEYS,
...AUTOMATION_SECTION_KEYS,
@@ -694,6 +1047,7 @@ export function renderApp(state: AppViewState) {
"wizard",
],
});
}
case "communications":
return renderConfigTab({
formMode: state.communicationsFormMode,
@@ -1293,6 +1647,7 @@ export function renderApp(state: AppViewState) {
)
: nothing}
${renderUsageTab(state)}
${state.tab === "cron" ? renderCronQuickCreateForTab(state, requestHostUpdate) : nothing}
${state.tab === "cron"
? lazyRender(lazyCron, (m) =>
m.renderCron({
@@ -1349,6 +1704,12 @@ export function renderApp(state: AppViewState) {
onToggle: (job, enabled) => toggleCronJob(state, job, enabled),
onRun: (job, mode) => runCronJob(state, job, mode ?? "force"),
onRemove: (job) => removeCronJob(state, job),
onQuickCreate: () => {
state.cronQuickCreateOpen = true;
state.cronQuickCreateStep = "what";
state.cronQuickCreateDraft = createDefaultDraft();
requestHostUpdate?.();
},
onLoadRuns: async (jobId) => {
updateCronRunsFilter(state, { cronRunsScope: "job" });
await loadCronRuns(state, jobId);

View File

@@ -149,6 +149,7 @@ export type AppViewState = {
wikiMemoryPalaceError: string | null;
wikiMemoryPalace: import("./controllers/dreaming.js").WikiMemoryPalace | null;
configFormMode: "form" | "raw";
configSettingsMode: "quick" | "advanced";
configSearchQuery: string;
configActiveSection: string | null;
configActiveSubsection: string | null;
@@ -274,6 +275,9 @@ export type AppViewState = {
} & Pick<
CronState,
| "cronLoading"
| "cronQuickCreateOpen"
| "cronQuickCreateStep"
| "cronQuickCreateDraft"
| "cronJobsLoadingMore"
| "cronJobs"
| "cronJobsTotal"

View File

@@ -251,6 +251,7 @@ export class OpenClawApp extends LitElement {
@state() wikiMemoryPalaceError: string | null = null;
@state() wikiMemoryPalace: WikiMemoryPalace | null = null;
@state() configFormDirty = false;
@state() configSettingsMode: "quick" | "advanced" = "quick";
@state() configFormMode: "form" | "raw" = "form";
@state() configSearchQuery = "";
@state() configActiveSection: string | null = null;
@@ -396,6 +397,11 @@ export class OpenClawApp extends LitElement {
usageQueryDebounceTimer: number | null = null;
@state() cronLoading = false;
@state() cronQuickCreateOpen = false;
@state() cronQuickCreateStep: import("./views/cron-quick-create.ts").CronQuickCreateStep = "what";
@state() cronQuickCreateDraft:
| import("./views/cron-quick-create.ts").CronQuickCreateDraft
| null = null;
@state() cronJobsLoadingMore = false;
@state() cronJobs: CronJob[] = [];
@state() cronJobsTotal = 0;

View File

@@ -6,6 +6,8 @@ import { normalizeLowercaseStringOrEmpty } from "../string-coerce.ts";
export type SlashCommandCategory = "session" | "model" | "agents" | "tools";
export type SlashCommandTier = "essential" | "standard" | "power";
export type SlashCommandDef = {
key: string;
name: string;
@@ -20,6 +22,8 @@ export type SlashCommandDef = {
argOptions?: string[];
/** Keyboard shortcut hint shown in the menu (display only). */
shortcut?: string;
/** Progressive disclosure tier. Defaults to "standard" when omitted. */
tier?: SlashCommandTier;
};
type LocalArgChoice = string | { value: string; label: string };
@@ -35,6 +39,7 @@ type CommandLike = {
choices?: LocalArgChoice[];
}>;
category?: string;
tier?: string;
};
const REMOTE_SLASH_IDENTIFIER_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u;
@@ -101,6 +106,7 @@ const UI_ONLY_COMMANDS: SlashCommandDef[] = [
icon: "trash",
category: "session",
executeLocal: true,
tier: "standard",
},
{
key: "redirect",
@@ -110,6 +116,7 @@ const UI_ONLY_COMMANDS: SlashCommandDef[] = [
icon: "refresh",
category: "agents",
executeLocal: true,
tier: "power",
},
];
@@ -213,6 +220,14 @@ function mapIcon(command: CommandLike): IconName | undefined {
return COMMAND_ICON_OVERRIDES[normalizeUiKey(command)] ?? "terminal";
}
function mapTier(command: CommandLike): SlashCommandTier {
const raw = command.tier;
if (raw === "essential" || raw === "standard" || raw === "power") {
return raw;
}
return "standard";
}
function toSlashCommand(
command: CommandLike,
source: "local" | "remote" = "local",
@@ -231,6 +246,7 @@ function toSlashCommand(
category: mapCategory(command),
executeLocal: source === "local" && LOCAL_COMMANDS.has(command.key),
argOptions: getArgOptions(command),
tier: source === "local" ? mapTier(command) : "standard",
};
}
@@ -309,6 +325,7 @@ function buildLocalSlashCommands(): SlashCommandDef[] {
choices: Array.isArray(arg.choices) ? arg.choices : undefined,
})),
category: command.category,
tier: command.tier,
}))
.map((command) => toSlashCommand(command, "local"))
.filter((command): command is SlashCommandDef => command !== null);
@@ -454,9 +471,19 @@ export const CATEGORY_LABELS: Record<SlashCommandCategory, string> = {
tools: "Tools",
};
export function getSlashCommandCompletions(filter: string): SlashCommandDef[] {
const TIER_ORDER: Record<SlashCommandTier, number> = {
essential: 0,
standard: 1,
power: 2,
};
export function getSlashCommandCompletions(
filter: string,
options?: { showAll?: boolean },
): SlashCommandDef[] {
const lower = normalizeLowercaseStringOrEmpty(filter);
const commands = lower
const showAll = options?.showAll ?? false;
let commands = lower
? SLASH_COMMANDS.filter(
(cmd) =>
cmd.name.startsWith(lower) ||
@@ -464,7 +491,19 @@ export function getSlashCommandCompletions(filter: string): SlashCommandDef[] {
normalizeLowercaseStringOrEmpty(cmd.description).includes(lower),
)
: SLASH_COMMANDS;
// When no filter text and not explicitly showing all, hide "power" tier commands
if (!lower && !showAll) {
commands = commands.filter((cmd) => (cmd.tier ?? "standard") !== "power");
}
return commands.toSorted((a, b) => {
// Sort by tier first (essential → standard → power)
const aTier = TIER_ORDER[a.tier ?? "standard"] ?? 1;
const bTier = TIER_ORDER[b.tier ?? "standard"] ?? 1;
if (aTier !== bTier) {
return aTier - bTier;
}
const ai = CATEGORY_ORDER.indexOf(a.category ?? "session");
const bi = CATEGORY_ORDER.indexOf(b.category ?? "session");
if (ai !== bi) {
@@ -481,6 +520,11 @@ export function getSlashCommandCompletions(filter: string): SlashCommandDef[] {
});
}
/** Count of commands hidden by tier filtering (for "Show N more" UI). */
export function getHiddenCommandCount(): number {
return SLASH_COMMANDS.filter((cmd) => (cmd.tier ?? "standard") === "power").length;
}
export type ParsedSlashCommand = {
command: SlashCommandDef;
args: string;

View File

@@ -19,6 +19,9 @@ function createState(overrides: Partial<CronState> = {}): CronState {
client: null,
connected: true,
cronLoading: false,
cronQuickCreateOpen: false,
cronQuickCreateStep: "what",
cronQuickCreateDraft: null,
cronJobsLoadingMore: false,
cronJobs: [],
cronJobsTotal: 0,

View File

@@ -47,6 +47,9 @@ export type CronState = {
client: GatewayBrowserClient | null;
connected: boolean;
cronLoading: boolean;
cronQuickCreateOpen: boolean;
cronQuickCreateStep: import("../views/cron-quick-create.ts").CronQuickCreateStep;
cronQuickCreateDraft: import("../views/cron-quick-create.ts").CronQuickCreateDraft | null;
cronJobsLoadingMore: boolean;
cronJobs: CronJob[];
cronJobsTotal: number;

View File

@@ -29,6 +29,7 @@ import type { ChatSideResult } from "../chat/side-result.ts";
import {
CATEGORY_LABELS,
SLASH_COMMANDS,
getHiddenCommandCount,
getSlashCommandCompletions,
type SlashCommandCategory,
type SlashCommandDef,
@@ -292,6 +293,7 @@ interface ChatEphemeralState {
slashMenuMode: "command" | "args";
slashMenuCommand: SlashCommandDef | null;
slashMenuArgItems: string[];
slashMenuExpanded: boolean;
searchOpen: boolean;
searchQuery: string;
pinnedExpanded: boolean;
@@ -307,6 +309,7 @@ function createChatEphemeralState(): ChatEphemeralState {
slashMenuMode: "command",
slashMenuCommand: null,
slashMenuArgItems: [],
slashMenuExpanded: false,
searchOpen: false,
searchQuery: "",
pinnedExpanded: false,
@@ -725,6 +728,7 @@ function resetSlashMenuState(): void {
vs.slashMenuCommand = null;
vs.slashMenuArgItems = [];
vs.slashMenuItems = [];
vs.slashMenuExpanded = false;
}
function updateSlashMenu(value: string, requestUpdate: () => void): void {
@@ -758,7 +762,7 @@ function updateSlashMenu(value: string, requestUpdate: () => void): void {
// Command mode: /partial-command
const match = value.match(/^\/(\S*)$/);
if (match) {
const items = getSlashCommandCompletions(match[1]);
const items = getSlashCommandCompletions(match[1], { showAll: vs.slashMenuExpanded });
vs.slashMenuItems = items;
vs.slashMenuOpen = items.length > 0;
vs.slashMenuIndex = 0;
@@ -1107,9 +1111,24 @@ function renderSlashMenu(
`);
}
const hiddenCount = vs.slashMenuExpanded ? 0 : getHiddenCommandCount();
return html`
<div class="slash-menu" role="listbox" aria-label="Slash commands">
${sections}
${hiddenCount > 0
? html`<button
class="slash-menu-show-more"
@click=${(e: Event) => {
e.preventDefault();
e.stopPropagation();
vs.slashMenuExpanded = true;
updateSlashMenu(props.draft, requestUpdate);
}}
>
Show ${hiddenCount} more command${hiddenCount !== 1 ? "s" : ""}
</button>`
: nothing}
<div class="slash-menu-footer">
<kbd>↑↓</kbd> navigate <kbd>Tab</kbd> fill <kbd>Enter</kbd> select <kbd>Esc</kbd> close
</div>

View File

@@ -0,0 +1,109 @@
/**
* Config presets — opinionated configuration bundles that set multiple
* settings at once. Applied via config.patch.
*/
export type ConfigPresetId = "personal" | "codeAgent" | "teamBot" | "minimal";
export type ConfigPreset = {
id: ConfigPresetId;
label: string;
description: string;
icon: string;
patch: Record<string, unknown>;
};
export const CONFIG_PRESETS: ConfigPreset[] = [
{
id: "personal",
label: "Personal Assistant",
description: "Balanced context and cost. Best for daily use.",
icon: "✨",
patch: {
agents: {
defaults: {
bootstrapMaxChars: 20_000,
bootstrapTotalMaxChars: 150_000,
contextInjection: "always",
},
},
},
},
{
id: "codeAgent",
label: "Code Agent",
description: "Higher context for coding tasks. More tokens per turn.",
icon: "🛠️",
patch: {
agents: {
defaults: {
bootstrapMaxChars: 50_000,
bootstrapTotalMaxChars: 300_000,
contextInjection: "always",
},
},
},
},
{
id: "teamBot",
label: "Team Bot",
description: "Multi-channel, group-aware. Leaner per-turn context.",
icon: "👥",
patch: {
agents: {
defaults: {
bootstrapMaxChars: 10_000,
bootstrapTotalMaxChars: 80_000,
contextInjection: "continuation-skip",
},
},
},
},
{
id: "minimal",
label: "Minimal",
description: "Lowest cost per turn. Fast and lean.",
icon: "⚡",
patch: {
agents: {
defaults: {
bootstrapMaxChars: 5_000,
bootstrapTotalMaxChars: 30_000,
contextInjection: "continuation-skip",
},
},
},
},
];
export function getPresetById(id: ConfigPresetId): ConfigPreset | undefined {
return CONFIG_PRESETS.find((p) => p.id === id);
}
/**
* Detect which preset (if any) matches the current config values.
*/
export function detectActivePreset(config: Record<string, unknown>): ConfigPresetId | null {
const agents = config.agents as Record<string, unknown> | undefined;
const defaults = agents?.defaults as Record<string, unknown> | undefined;
if (!defaults) {
return "personal"; // treat unset as default
}
const maxChars = defaults.bootstrapMaxChars;
const totalMax = defaults.bootstrapTotalMaxChars;
for (const preset of CONFIG_PRESETS) {
const presetDefaults = (preset.patch.agents as Record<string, unknown>)?.defaults as
| Record<string, unknown>
| undefined;
if (!presetDefaults) {
continue;
}
if (
maxChars === presetDefaults.bootstrapMaxChars &&
totalMax === presetDefaults.bootstrapTotalMaxChars
) {
return preset.id;
}
}
return null;
}

View File

@@ -0,0 +1,442 @@
/**
* Quick Settings view — opinionated card layout for the most common settings.
* Replaces the raw schema-driven form as the default settings experience.
*
* Each card answers a "what do I want to do?" question with status + actions.
*/
import { html, nothing, type TemplateResult } from "lit";
import { icons } from "../icons.ts";
import type { BorderRadiusStop } from "../storage.ts";
import type { ThemeTransitionContext } from "../theme-transition.ts";
import type { ThemeMode, ThemeName } from "../theme.ts";
import { CONFIG_PRESETS, detectActivePreset, type ConfigPresetId } from "./config-presets.ts";
// ── Types ──
export type QuickSettingsChannel = {
id: string;
label: string;
connected: boolean;
detail?: string;
};
export type QuickSettingsApiKey = {
provider: string;
label: string;
masked?: string;
isSet: boolean;
};
export type QuickSettingsAutomation = {
cronJobCount: number;
skillCount: number;
mcpServerCount: number;
};
export type QuickSettingsSecurity = {
gatewayAuth: string;
execPolicy: string;
deviceAuth: boolean;
};
export type QuickSettingsProps = {
// Model & Thinking
currentModel: string;
thinkingLevel: string;
fastMode: boolean;
onModelChange?: () => void;
onThinkingChange?: (level: string) => void;
onFastModeToggle?: () => void;
// Channels
channels: QuickSettingsChannel[];
onChannelConfigure?: (channelId: string) => void;
// API Keys
apiKeys: QuickSettingsApiKey[];
onApiKeyChange?: (provider: string) => void;
// Automations
automation: QuickSettingsAutomation;
onManageCron?: () => void;
onBrowseSkills?: () => void;
onConfigureMcp?: () => void;
// Security
security: QuickSettingsSecurity;
onSecurityConfigure?: () => void;
// Appearance
theme: ThemeName;
themeMode: ThemeMode;
borderRadius: number;
setTheme: (theme: ThemeName, context?: ThemeTransitionContext) => void;
setThemeMode: (mode: ThemeMode, context?: ThemeTransitionContext) => void;
setBorderRadius: (value: number) => void;
// Presets
configObject?: Record<string, unknown>;
onApplyPreset?: (presetId: ConfigPresetId) => void;
// Navigation
onAdvancedSettings?: () => void;
// Connection
connected: boolean;
gatewayUrl: string;
assistantName: string;
version: string;
};
// ── Theme options ──
type ThemeOption = { id: ThemeName; label: string };
const THEME_OPTIONS: ThemeOption[] = [
{ id: "claw", label: "Claw" },
{ id: "knot", label: "Knot" },
{ id: "dash", label: "Dash" },
];
const BORDER_RADIUS_STOPS: Array<{ value: BorderRadiusStop; label: string }> = [
{ value: 0, label: "None" },
{ value: 25, label: "Slight" },
{ value: 50, label: "Default" },
{ value: 75, label: "Round" },
{ value: 100, label: "Full" },
];
const THINKING_LEVELS = ["off", "low", "medium", "high"];
// ── Card renderers ──
function renderCardHeader(icon: TemplateResult, title: string, action?: TemplateResult) {
return html`
<div class="qs-card__header">
<div class="qs-card__header-left">
<span class="qs-card__icon">${icon}</span>
<h3 class="qs-card__title">${title}</h3>
</div>
${action ? action : nothing}
</div>
`;
}
function renderModelCard(props: QuickSettingsProps) {
return html`
<div class="qs-card">
${renderCardHeader(icons.brain, "Model & Thinking")}
<div class="qs-card__body">
<div class="qs-row">
<span class="qs-row__label">Model</span>
<button class="qs-row__value qs-row__value--action" @click=${props.onModelChange}>
<code>${props.currentModel || "default"}</code>
<span class="qs-row__chevron">${icons.chevronRight}</span>
</button>
</div>
<div class="qs-row">
<span class="qs-row__label">Thinking</span>
<div class="qs-segmented">
${THINKING_LEVELS.map(
(level) => html`
<button
class="qs-segmented__btn ${level === props.thinkingLevel
? "qs-segmented__btn--active"
: ""}"
@click=${() => props.onThinkingChange?.(level)}
>
${level.charAt(0).toUpperCase() + level.slice(1)}
</button>
`,
)}
</div>
</div>
<div class="qs-row">
<span class="qs-row__label">Fast mode</span>
<label class="qs-toggle">
<input type="checkbox" .checked=${props.fastMode} @change=${props.onFastModeToggle} />
<span class="qs-toggle__track"></span>
<span class="qs-toggle__hint muted"
>${props.fastMode ? "On — cheaper, less capable" : "Off"}</span
>
</label>
</div>
</div>
</div>
`;
}
function renderChannelsCard(props: QuickSettingsProps) {
const connectedCount = props.channels.filter((c) => c.connected).length;
const badge =
connectedCount > 0
? html`<span class="qs-badge qs-badge--ok">${connectedCount} connected</span>`
: undefined;
return html`
<div class="qs-card">
${renderCardHeader(icons.send, "Channels", badge)}
<div class="qs-card__body">
${props.channels.length === 0
? html`<div class="qs-empty muted">No channels configured</div>`
: props.channels.map(
(ch) => html`
<div class="qs-row">
<span class="qs-row__label">
<span class="qs-status-dot ${ch.connected ? "qs-status-dot--ok" : ""}"></span>
${ch.label}
</span>
<span class="qs-row__value">
${ch.connected
? html`<span class="muted">${ch.detail ?? "Connected"}</span>`
: html`<button
class="qs-link-btn"
@click=${() => props.onChannelConfigure?.(ch.id)}
>
Connect →
</button>`}
</span>
</div>
`,
)}
</div>
</div>
`;
}
function renderApiKeysCard(props: QuickSettingsProps) {
return html`
<div class="qs-card">
${renderCardHeader(icons.plug, "API Keys")}
<div class="qs-card__body">
${props.apiKeys.length === 0
? html`<div class="qs-empty muted">No API keys configured</div>`
: props.apiKeys.map(
(key) => html`
<div class="qs-row">
<span class="qs-row__label">${key.label}</span>
<span class="qs-row__value">
${key.isSet
? html`
<code class="qs-masked">${key.masked ?? "••••••••"}</code>
<button
class="qs-link-btn"
@click=${() => props.onApiKeyChange?.(key.provider)}
>
Change
</button>
`
: html`<button
class="qs-link-btn"
@click=${() => props.onApiKeyChange?.(key.provider)}
>
Add →
</button>`}
</span>
</div>
`,
)}
</div>
</div>
`;
}
function renderAutomationsCard(props: QuickSettingsProps) {
const { cronJobCount, skillCount, mcpServerCount } = props.automation;
return html`
<div class="qs-card">
${renderCardHeader(icons.zap, "Automations")}
<div class="qs-card__body">
<div class="qs-row">
<span class="qs-row__label">
${cronJobCount} scheduled task${cronJobCount !== 1 ? "s" : ""}
</span>
<button class="qs-link-btn" @click=${props.onManageCron}>Manage →</button>
</div>
<div class="qs-row">
<span class="qs-row__label">
${skillCount} skill${skillCount !== 1 ? "s" : ""} installed
</span>
<button class="qs-link-btn" @click=${props.onBrowseSkills}>Browse →</button>
</div>
<div class="qs-row">
<span class="qs-row__label">
${mcpServerCount} MCP server${mcpServerCount !== 1 ? "s" : ""}
</span>
<button class="qs-link-btn" @click=${props.onConfigureMcp}>Configure →</button>
</div>
</div>
</div>
`;
}
function renderSecurityCard(props: QuickSettingsProps) {
const { gatewayAuth, execPolicy, deviceAuth } = props.security;
return html`
<div class="qs-card">
${renderCardHeader(
icons.eye,
"Security",
html`<button class="qs-link-btn" @click=${props.onSecurityConfigure}>Configure →</button>`,
)}
<div class="qs-card__body">
<div class="qs-row">
<span class="qs-row__label">Gateway auth</span>
<span class="qs-row__value">
<span class="qs-badge ${gatewayAuth !== "none" ? "qs-badge--ok" : "qs-badge--warn"}"
>${gatewayAuth}</span
>
</span>
</div>
<div class="qs-row">
<span class="qs-row__label">Exec policy</span>
<span class="qs-row__value"><span class="qs-badge">${execPolicy}</span></span>
</div>
<div class="qs-row">
<span class="qs-row__label">Device auth</span>
<span class="qs-row__value">
<span class="qs-badge ${deviceAuth ? "qs-badge--ok" : "qs-badge--warn"}"
>${deviceAuth ? "Enabled" : "Disabled"}</span
>
</span>
</div>
</div>
</div>
`;
}
function renderAppearanceCard(props: QuickSettingsProps) {
return html`
<div class="qs-card">
${renderCardHeader(icons.spark, "Appearance")}
<div class="qs-card__body">
<div class="qs-row">
<span class="qs-row__label">Theme</span>
<div class="qs-segmented">
${THEME_OPTIONS.map(
(opt) => html`
<button
class="qs-segmented__btn ${opt.id === props.theme
? "qs-segmented__btn--active"
: ""}"
@click=${(e: Event) => {
if (opt.id !== props.theme) {
props.setTheme(opt.id, {
element: (e.currentTarget as HTMLElement) ?? undefined,
});
}
}}
>
${opt.label}
</button>
`,
)}
</div>
</div>
<div class="qs-row">
<span class="qs-row__label">Mode</span>
<div class="qs-segmented">
${(["light", "dark", "system"] as ThemeMode[]).map(
(mode) => html`
<button
class="qs-segmented__btn ${mode === props.themeMode
? "qs-segmented__btn--active"
: ""}"
@click=${(e: Event) => {
if (mode !== props.themeMode) {
props.setThemeMode(mode, {
element: (e.currentTarget as HTMLElement) ?? undefined,
});
}
}}
>
${mode.charAt(0).toUpperCase() + mode.slice(1)}
</button>
`,
)}
</div>
</div>
<div class="qs-row">
<span class="qs-row__label">Roundness</span>
<div class="qs-segmented">
${BORDER_RADIUS_STOPS.map(
(stop) => html`
<button
class="qs-segmented__btn qs-segmented__btn--compact ${stop.value ===
props.borderRadius
? "qs-segmented__btn--active"
: ""}"
@click=${() => props.setBorderRadius(stop.value)}
>
${stop.label}
</button>
`,
)}
</div>
</div>
</div>
</div>
`;
}
function renderPresetsCard(props: QuickSettingsProps) {
const activePreset = props.configObject ? detectActivePreset(props.configObject) : "personal";
return html`
<div class="qs-card qs-card--span-all">
${renderCardHeader(icons.zap, "Profile")}
<div class="qs-card__body qs-presets-grid">
${CONFIG_PRESETS.map(
(preset) => html`
<button
class="qs-preset ${preset.id === activePreset ? "qs-preset--active" : ""}"
@click=${() => props.onApplyPreset?.(preset.id)}
>
<span class="qs-preset__icon">${preset.icon}</span>
<span class="qs-preset__label">${preset.label}</span>
<span class="qs-preset__desc muted">${preset.description}</span>
</button>
`,
)}
</div>
</div>
`;
}
function renderConnectionFooter(props: QuickSettingsProps) {
return html`
<div class="qs-footer">
<div class="qs-footer__row">
<span class="qs-status-dot ${props.connected ? "qs-status-dot--ok" : ""}"></span>
<span class="muted">${props.connected ? "Connected" : "Offline"}</span>
${props.assistantName ? html`<span class="muted">· ${props.assistantName}</span>` : nothing}
${props.version ? html`<span class="muted">· v${props.version}</span>` : nothing}
</div>
</div>
`;
}
// ── Main render ──
export function renderQuickSettings(props: QuickSettingsProps) {
return html`
<div class="qs-container">
<div class="qs-header">
<h2 class="qs-header__title">${icons.settings} Settings</h2>
<button class="btn btn--sm" @click=${props.onAdvancedSettings}>
Advanced ${icons.chevronRight}
</button>
</div>
<div class="qs-grid">
${renderModelCard(props)} ${renderChannelsCard(props)} ${renderApiKeysCard(props)}
${renderAutomationsCard(props)} ${renderSecurityCard(props)} ${renderAppearanceCard(props)}
${renderPresetsCard(props)}
</div>
${renderConnectionFooter(props)}
</div>
`;
}

View File

@@ -70,6 +70,10 @@ export type ConfigProps = {
includeSections?: string[];
excludeSections?: string[];
includeVirtualSections?: boolean;
/** Layout mode: "tabs" (default flat scroll) or "accordion" (grouped collapsible). */
settingsLayout?: "tabs" | "accordion";
/** Callback to navigate back to Quick Settings. Shown in accordion mode. */
onBackToQuick?: () => void;
onRequestUpdate?: () => void;
};
@@ -760,6 +764,93 @@ export function renderConfig(props: ConfigProps) {
),
];
const settingsLayout = props.settingsLayout ?? "tabs";
const allCategories = [...visibleCategories, ...(otherCategory ? [otherCategory] : [])];
function renderAccordionNav() {
return html`
<div class="config-accordion-nav">
${props.onBackToQuick
? html`
<button class="config-accordion-nav__back" @click=${props.onBackToQuick}>
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
Quick Settings
</button>
`
: nothing}
${allCategories.map(
(cat) => html`
<div class="config-accordion-group">
<button
class="config-accordion-group__header ${props.activeSection != null &&
cat.sections.some((s) => s.key === props.activeSection)
? "config-accordion-group__header--active"
: ""}"
@click=${() => {
const firstKey = cat.sections[0]?.key ?? null;
const isCurrentlyInGroup = cat.sections.some(
(s) => s.key === props.activeSection,
);
props.onSectionChange(isCurrentlyInGroup ? null : firstKey);
}}
>
<span class="config-accordion-group__icon">
${getSectionIcon(cat.sections[0]?.key ?? "default")}
</span>
<span>${cat.label}</span>
<svg
class="config-accordion-group__chevron ${cat.sections.some(
(s) => s.key === props.activeSection,
)
? "config-accordion-group__chevron--open"
: ""}"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
width="14"
height="14"
>
<polyline points="6 9 12 15 18 9"></polyline>
</svg>
</button>
${cat.sections.some((s) => s.key === props.activeSection)
? html`
<div class="config-accordion-group__items">
${cat.sections.map(
(s) => html`
<button
class="config-accordion-group__item ${props.activeSection === s.key
? "config-accordion-group__item--active"
: ""}"
@click=${() => props.onSectionChange(s.key)}
>
<span class="config-accordion-group__item-icon">
${getSectionIcon(s.key)}
</span>
${s.label}
</button>
`,
)}
</div>
`
: nothing}
</div>
`,
)}
</div>
`;
}
// Compute diff for showing changes (works for both form and raw modes)
const diff = formMode === "form" ? computeDiff(props.originalValue, props.formValue) : [];
const hasRawChanges = formMode === "raw" && props.raw !== props.originalRaw;
@@ -857,67 +948,72 @@ export function renderConfig(props: ConfigProps) {
</div>
</div>
<div class="config-top-tabs">
${formMode === "form"
? html`
<div class="config-search config-search--top">
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
aria-label="Search settings"
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${props.searchQuery
? html`
<button
class="config-search__clear"
aria-label="Clear search"
@click=${() => props.onSearchChange("")}
${settingsLayout === "accordion"
? renderAccordionNav()
: html`
<div class="config-top-tabs">
${formMode === "form"
? html`
<div class="config-search config-search--top">
<div class="config-search__input-row">
<svg
class="config-search__icon"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
×
</button>
`
: nothing}
</div>
</div>
`
: nothing}
<circle cx="11" cy="11" r="8"></circle>
<path d="M21 21l-4.35-4.35"></path>
</svg>
<input
type="text"
class="config-search__input"
placeholder="Search settings..."
aria-label="Search settings"
.value=${props.searchQuery}
@input=${(e: Event) =>
props.onSearchChange((e.target as HTMLInputElement).value)}
/>
${props.searchQuery
? html`
<button
class="config-search__clear"
aria-label="Clear search"
@click=${() => props.onSearchChange("")}
>
×
</button>
`
: nothing}
</div>
</div>
`
: nothing}
<div
class="config-top-tabs__scroller"
role="tablist"
aria-label="${t("common.settingsSections")}"
>
${topTabs.map(
(tab) => html`
<button
class="config-top-tabs__tab ${props.activeSection === tab.key ? "active" : ""}"
role="tab"
aria-selected=${props.activeSection === tab.key}
@click=${() => props.onSectionChange(tab.key)}
title=${tab.label}
<div
class="config-top-tabs__scroller"
role="tablist"
aria-label="${t("common.settingsSections")}"
>
${tab.label}
</button>
`,
)}
</div>
</div>
${topTabs.map(
(tab) => html`
<button
class="config-top-tabs__tab ${props.activeSection === tab.key
? "active"
: ""}"
role="tab"
aria-selected=${props.activeSection === tab.key}
@click=${() => props.onSectionChange(tab.key)}
title=${tab.label}
>
${tab.label}
</button>
`,
)}
</div>
</div>
`}
${validity === "invalid" && !cvs.validityDismissed
? html`
<div class="config-validity-warning">

View File

@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { draftToCronFormPatch, type CronQuickCreateDraft } from "./cron-quick-create.ts";
function createDraft(overrides: Partial<CronQuickCreateDraft> = {}): CronQuickCreateDraft {
return {
prompt: "Check inbox",
name: "Inbox check",
schedulePreset: "every-morning",
deliveryPreset: "notify",
...overrides,
};
}
describe("cron quick create", () => {
it("sets a valid scheduleAt for one-time presets", () => {
const patch = draftToCronFormPatch(createDraft({ schedulePreset: "once" }));
expect(patch.scheduleKind).toBe("at");
expect(patch.deleteAfterRun).toBe(true);
expect(typeof patch.scheduleAt).toBe("string");
expect(Date.parse(String(patch.scheduleAt))).not.toBeNaN();
});
it("clears deleteAfterRun and scheduleAt for recurring presets", () => {
const patch = draftToCronFormPatch(createDraft({ schedulePreset: "weekly" }));
expect(patch.scheduleKind).toBe("cron");
expect(patch.cronExpr).toBe("0 9 * * 1");
expect(patch.deleteAfterRun).toBe(false);
expect(patch.scheduleAt).toBe("");
});
it("keeps notify preset announce-capable by targeting an isolated session", () => {
const patch = draftToCronFormPatch(createDraft({ deliveryPreset: "notify" }));
expect(patch.sessionTarget).toBe("isolated");
expect(patch.deliveryMode).toBe("announce");
expect(patch.wakeMode).toBe("now");
});
});

View File

@@ -0,0 +1,319 @@
/**
* Simplified automation creation flow — Routines-style guided wizard.
*
* "What should it do?" → "When should it run?" → "How should it deliver?"
*
* Maps to the existing CronFormState fields under the hood.
*/
import { html, nothing } from "lit";
import { icons } from "../icons.ts";
import type { CronFormState } from "../ui-types.ts";
// ── Types ──
export type CronQuickCreateProps = {
open: boolean;
step: CronQuickCreateStep;
draft: CronQuickCreateDraft;
onDraftChange: (patch: Partial<CronQuickCreateDraft>) => void;
onStepChange: (step: CronQuickCreateStep) => void;
onCreate: () => void;
onCancel: () => void;
};
export type CronQuickCreateStep = "what" | "when" | "how";
export type CronQuickCreateDraft = {
prompt: string;
name: string;
schedulePreset: SchedulePresetId | "custom";
deliveryPreset: DeliveryPresetId;
};
type SchedulePresetId =
| "every-morning"
| "every-evening"
| "hourly"
| "weekdays"
| "weekly"
| "once";
type DeliveryPresetId = "notify" | "silent" | "isolated";
// ── Presets ──
type SchedulePreset = {
id: SchedulePresetId;
label: string;
icon: string;
description: string;
};
const SCHEDULE_PRESETS: SchedulePreset[] = [
{ id: "every-morning", label: "Every morning", icon: "🌅", description: "Daily at 8:00 AM" },
{ id: "every-evening", label: "Every evening", icon: "🌙", description: "Daily at 6:00 PM" },
{ id: "hourly", label: "Hourly", icon: "🔄", description: "Every hour" },
{ id: "weekdays", label: "Weekdays", icon: "📅", description: "MonFri at 9:00 AM" },
{ id: "weekly", label: "Weekly", icon: "📆", description: "Every Monday at 9:00 AM" },
{ id: "once", label: "Run once", icon: "⚡", description: "One-time, delete after run" },
];
type DeliveryPreset = {
id: DeliveryPresetId;
label: string;
description: string;
};
const DELIVERY_PRESETS: DeliveryPreset[] = [
{ id: "notify", label: "Notify me", description: "Deliver results to chat" },
{ id: "silent", label: "Silent", description: "Run without notification" },
{ id: "isolated", label: "Independent session", description: "Run in its own session" },
];
// ── Default draft ──
export function createDefaultDraft(): CronQuickCreateDraft {
return {
prompt: "",
name: "",
schedulePreset: "every-morning",
deliveryPreset: "notify",
};
}
function buildDefaultScheduleAt(now = new Date()): string {
const next = new Date(now);
next.setHours(next.getHours() + 1, 0, 0, 0);
const year = next.getFullYear();
const month = String(next.getMonth() + 1).padStart(2, "0");
const day = String(next.getDate()).padStart(2, "0");
const hour = String(next.getHours()).padStart(2, "0");
const minute = String(next.getMinutes()).padStart(2, "0");
return `${year}-${month}-${day}T${hour}:${minute}`;
}
// ── Convert draft to CronFormState patch ──
export function draftToCronFormPatch(draft: CronQuickCreateDraft): Partial<CronFormState> {
const patch: Partial<CronFormState> = {
name: draft.name || "Automation",
payloadKind: "agentTurn",
deleteAfterRun: false,
scheduleAt: "",
payloadText: draft.prompt,
enabled: true,
};
// Schedule
switch (draft.schedulePreset) {
case "every-morning":
patch.scheduleKind = "cron";
patch.cronExpr = "0 8 * * *";
break;
case "every-evening":
patch.scheduleKind = "cron";
patch.cronExpr = "0 18 * * *";
break;
case "hourly":
patch.scheduleKind = "every";
patch.everyAmount = "1";
patch.everyUnit = "hours";
break;
case "weekdays":
patch.scheduleKind = "cron";
patch.cronExpr = "0 9 * * 1-5";
break;
case "weekly":
patch.scheduleKind = "cron";
patch.cronExpr = "0 9 * * 1";
break;
case "once":
patch.scheduleKind = "at";
patch.scheduleAt = buildDefaultScheduleAt();
patch.deleteAfterRun = true;
break;
default:
break;
}
// Delivery
switch (draft.deliveryPreset) {
case "notify":
patch.sessionTarget = "isolated";
patch.deliveryMode = "announce";
patch.wakeMode = "now";
break;
case "silent":
patch.sessionTarget = "main";
patch.deliveryMode = "none";
patch.wakeMode = "now";
break;
case "isolated":
patch.sessionTarget = "isolated";
patch.deliveryMode = "none";
patch.wakeMode = "now";
break;
}
return patch;
}
// ── Step indicators ──
const STEPS: CronQuickCreateStep[] = ["what", "when", "how"];
const STEP_LABELS: Record<CronQuickCreateStep, string> = {
what: "What",
when: "When",
how: "How",
};
function renderStepIndicator(current: CronQuickCreateStep) {
const currentIdx = STEPS.indexOf(current);
return html`
<div class="cqc-steps">
${STEPS.map((step, idx) => {
const state = idx < currentIdx ? "done" : idx === currentIdx ? "active" : "pending";
return html`
<div class="cqc-step cqc-step--${state}">
<span class="cqc-step__dot">${state === "done" ? "✓" : idx + 1}</span>
<span class="cqc-step__label">${STEP_LABELS[step]}</span>
</div>
${idx < STEPS.length - 1
? html`<div class="cqc-step__line cqc-step__line--${state}"></div>`
: nothing}
`;
})}
</div>
`;
}
// ── Step renderers ──
function renderWhatStep(props: CronQuickCreateProps) {
return html`
<div class="cqc-body">
<h3 class="cqc-body__heading">What should it do?</h3>
<p class="cqc-body__hint muted">
Describe the task in natural language. The agent will run this prompt each time.
</p>
<textarea
class="cqc-textarea"
placeholder="e.g., Check my inbox for urgent emails and summarize them..."
rows="4"
.value=${props.draft.prompt}
@input=${(e: Event) =>
props.onDraftChange({ prompt: (e.target as HTMLTextAreaElement).value })}
></textarea>
<div class="cqc-field">
<label class="cqc-field__label">Name (optional)</label>
<input
class="cqc-input"
type="text"
placeholder="e.g., Morning inbox check"
.value=${props.draft.name}
@input=${(e: Event) =>
props.onDraftChange({ name: (e.target as HTMLInputElement).value })}
/>
</div>
</div>
<div class="cqc-actions">
<button class="btn" @click=${props.onCancel}>Cancel</button>
<button
class="btn primary"
?disabled=${!props.draft.prompt.trim()}
@click=${() => props.onStepChange("when")}
>
Next ${icons.chevronRight}
</button>
</div>
`;
}
function renderWhenStep(props: CronQuickCreateProps) {
return html`
<div class="cqc-body">
<h3 class="cqc-body__heading">When should it run?</h3>
<p class="cqc-body__hint muted">Pick a schedule. You can fine-tune it later.</p>
<div class="cqc-preset-grid">
${SCHEDULE_PRESETS.map(
(preset) => html`
<button
class="cqc-preset-card ${props.draft.schedulePreset === preset.id
? "cqc-preset-card--active"
: ""}"
@click=${() => props.onDraftChange({ schedulePreset: preset.id })}
>
<span class="cqc-preset-card__icon">${preset.icon}</span>
<span class="cqc-preset-card__label">${preset.label}</span>
<span class="cqc-preset-card__desc muted">${preset.description}</span>
</button>
`,
)}
</div>
</div>
<div class="cqc-actions">
<button class="btn" @click=${() => props.onStepChange("what")}>Back</button>
<button class="btn primary" @click=${() => props.onStepChange("how")}>
Next ${icons.chevronRight}
</button>
</div>
`;
}
function renderHowStep(props: CronQuickCreateProps) {
return html`
<div class="cqc-body">
<h3 class="cqc-body__heading">How should it work?</h3>
<p class="cqc-body__hint muted">Choose how results are delivered.</p>
<div class="cqc-delivery-options">
${DELIVERY_PRESETS.map(
(preset) => html`
<label
class="cqc-radio-card ${props.draft.deliveryPreset === preset.id
? "cqc-radio-card--active"
: ""}"
>
<input
type="radio"
name="delivery"
.checked=${props.draft.deliveryPreset === preset.id}
@change=${() => props.onDraftChange({ deliveryPreset: preset.id })}
/>
<span class="cqc-radio-card__label">${preset.label}</span>
<span class="cqc-radio-card__desc muted">${preset.description}</span>
</label>
`,
)}
</div>
</div>
<div class="cqc-actions">
<button class="btn" @click=${() => props.onStepChange("when")}>Back</button>
<button class="btn primary" @click=${props.onCreate}>Create ${icons.check}</button>
</div>
`;
}
// ── Main render ──
export function renderCronQuickCreate(props: CronQuickCreateProps) {
if (!props.open) {
return nothing;
}
return html`
<div class="cqc-container">
<div class="cqc-header">
<h2 class="cqc-header__title">${icons.zap} New Automation</h2>
<button class="cqc-header__close" @click=${props.onCancel}>${icons.x}</button>
</div>
${renderStepIndicator(props.step)}
${props.step === "what"
? renderWhatStep(props)
: props.step === "when"
? renderWhenStep(props)
: renderHowStep(props)}
</div>
`;
}

View File

@@ -71,6 +71,8 @@ export type CronProps = {
onToggle: (job: CronJob, enabled: boolean) => void;
onRun: (job: CronJob, mode?: "force" | "due") => void;
onRemove: (job: CronJob) => void;
/** Open the simplified creation wizard. */
onQuickCreate?: () => void;
onLoadRuns: (jobId: string) => void;
onLoadMoreJobs: () => void;
onJobsFiltersChange: (patch: {
@@ -418,6 +420,9 @@ export function renderCron(props: CronProps) {
</div>
</div>
<div class="cron-summary-strip__actions">
${props.onQuickCreate
? html` <button class="btn btn--primary" @click=${props.onQuickCreate}>+ New</button> `
: nothing}
<button
class=${props.loading ? "btn cron-refresh-btn--loading" : "btn"}
?disabled=${props.loading}