Add permissions slash command

This commit is contained in:
murashit 2026-07-01 17:14:07 +09:00
parent b74e049360
commit 12189e5ddd
10 changed files with 102 additions and 0 deletions

View file

@ -129,6 +129,13 @@ export const SLASH_COMMANDS = [
surface: "diagnostic",
detail: "Show current thread, context, and usage limits.",
},
{
command: "/permissions",
usage: "/permissions",
argsKind: "none",
surface: "diagnostic",
detail: "Show current permissions and approval settings.",
},
{
command: "/doctor",
usage: "/doctor",

View file

@ -26,6 +26,7 @@ export interface ConversationTurnActionsContext {
};
runtime: {
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
permissionDetails: () => MessageStreamNoticeSection[];
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];
@ -105,6 +106,7 @@ export function createConversationTurnActions(
addStructuredSystemMessage: status.addStructuredSystemMessage,
setStatus: status.set,
statusSummaryLines: runtime.statusSummaryLines,
permissionDetails: runtime.permissionDetails,
connectionDiagnosticDetails: runtime.connectionDiagnosticDetails,
toolInventoryDetails: runtime.toolInventoryDetails,
modelStatusLines: runtime.modelStatusLines,

View file

@ -52,6 +52,7 @@ export interface SlashCommandExecutionPorts {
clear: GoalActions["clear"];
};
statusSummaryLines: () => string[];
permissionDetails: () => MessageStreamNoticeSection[];
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
toolInventoryDetails: () => MessageStreamNoticeSection[] | Promise<MessageStreamNoticeSection[]>;
modelStatusLines: () => string[];
@ -183,6 +184,9 @@ export async function executeSlashCommand(
case "status":
context.addStructuredSystemMessage("Thread status", detailsFromLines(context.statusSummaryLines()));
return;
case "permissions":
context.addStructuredSystemMessage("Permissions & Approvals", context.permissionDetails());
return;
case "doctor":
context.addStructuredSystemMessage("Connection diagnostics", context.connectionDiagnosticDetails());
return;

View file

@ -45,6 +45,7 @@ export function createRuntimeBundle(
state: () => host.stateStore.getState(),
connected: () => input.connection.isConnected(),
configuredCommand: () => host.environment.plugin.settingsRef.settings.codexPath(),
vaultPath: () => host.environment.plugin.settingsRef.vaultPath,
nowMs: () => Date.now(),
}),
};

View file

@ -105,6 +105,7 @@ export function createTurnBundle(host: ChatPanelTurnHost, input: ChatPanelTurnIn
modelStatusLines: runtimeProjection.modelStatusLines,
effortStatusLines: runtimeProjection.effortStatusLines,
statusSummaryLines: runtimeProjection.statusSummaryLines,
permissionDetails: runtimeProjection.permissionDetails,
toolInventoryDetails: async () => {
if (host.stateStore.getState().connection.serverDiagnostics.toolInventory) {
return runtimeProjection.toolInventoryDetails();

View file

@ -4,6 +4,7 @@ import type { MessageStreamNoticeSection } from "../domain/message-stream/items"
import { collaborationModeLabel as formatCollaborationModeLabel } from "../domain/runtime/labels";
import type { RuntimeSnapshot } from "../domain/runtime/snapshot";
import { appServerDiagnosticSections } from "../presentation/runtime/diagnostic-sections";
import { runtimePermissionDetails } from "../presentation/runtime/permission-sections";
import {
effortStatusLines as buildEffortStatusLines,
modelStatusLines as buildModelStatusLines,
@ -13,6 +14,7 @@ import { toolInventoryDiagnosticSections } from "../presentation/runtime/tool-in
export interface ChatPanelRuntimeProjection {
connectionDiagnosticDetails: () => MessageStreamNoticeSection[];
permissionDetails: () => MessageStreamNoticeSection[];
modelStatusLines: () => string[];
effortStatusLines: () => string[];
statusSummaryLines: () => string[];
@ -23,12 +25,14 @@ interface ChatPanelRuntimeProjectionInput {
state: () => ChatState;
connected: () => boolean;
configuredCommand: () => string;
vaultPath: () => string;
nowMs: () => number;
}
export function createChatPanelRuntimeProjection(input: ChatPanelRuntimeProjectionInput): ChatPanelRuntimeProjection {
return {
connectionDiagnosticDetails: () => connectionDiagnosticDetails(input),
permissionDetails: () => permissionDetails(input),
modelStatusLines: () => modelStatusLines(input),
effortStatusLines: () => effortStatusLines(input),
statusSummaryLines: () => statusSummaryLines(input),
@ -79,6 +83,16 @@ function toolInventoryDetails(input: ChatPanelRuntimeProjectionInput): MessageSt
return noticeSectionsFromDiagnostics(toolInventoryDiagnosticSections(input.state().connection.serverDiagnostics));
}
function permissionDetails(input: ChatPanelRuntimeProjectionInput): MessageStreamNoticeSection[] {
const state = input.state();
return noticeSectionsFromDiagnostics(
runtimePermissionDetails({
snapshot: runtimeSnapshot(state),
vaultPath: input.vaultPath(),
}).sections,
);
}
function noticeSectionsFromDiagnostics(
sections: readonly { title: string; rows: readonly { label: string; value: string }[] }[],
): MessageStreamNoticeSection[] {

View file

@ -47,6 +47,7 @@ function context(overrides: Partial<SlashCommandExecutionContext> = {}): SlashCo
clear: vi.fn().mockResolvedValue(true),
},
statusSummaryLines: () => ["status"],
permissionDetails: () => [{ title: "Permissions", auditFacts: [{ key: "Profile", value: "read-only" }] }],
connectionDiagnosticDetails: () => [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }],
toolInventoryDetails: vi.fn(() => [{ title: "Tool providers", auditFacts: [{ key: "codex_apps", value: "github" }] }]),
modelStatusLines: () => ["model"],
@ -559,6 +560,28 @@ describe("slash commands", () => {
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/status does not take arguments. Usage: /status");
});
it("shows permissions and approvals as shared structured sections", async () => {
const details = [
{ title: "Permissions", auditFacts: [{ key: "Profile", value: "workspace-write" }] },
{ title: "Approvals", auditFacts: [{ key: "Reviewer", value: "auto_review" }] },
];
const ctx = context({ permissionDetails: () => details });
await executeSlashCommand("permissions", "", ctx);
expect(ctx.addSystemMessage).not.toHaveBeenCalled();
expect(ctx.addStructuredSystemMessage).toHaveBeenCalledWith("Permissions & Approvals", details);
});
it("rejects /permissions arguments", async () => {
const ctx = context();
await executeSlashCommand("permissions", "anything", ctx);
expect(ctx.addStructuredSystemMessage).not.toHaveBeenCalled();
expect(ctx.addSystemMessage).toHaveBeenCalledWith("/permissions does not take arguments. Usage: /permissions");
});
it("shows doctor diagnostics as shared structured sections", async () => {
const details = [{ title: "Process", rows: [{ key: "connection", value: "connected" }] }];
const ctx = context({ connectionDiagnosticDetails: () => details });
@ -596,6 +619,7 @@ describe("slash commands", () => {
it("documents status and doctor as separate commands", () => {
expect(slashCommandHelpValue("/status")).toBe("Show current thread, context, and usage limits.");
expect(slashCommandHelpValue("/permissions")).toBe("Show current permissions and approval settings.");
expect(slashCommandHelpValue("/doctor")).toBe("Show Codex CLI and Codex App Server diagnostics.");
});

View file

@ -62,6 +62,7 @@ function createHost(overrides: SlashCommandExecutorHostOverrides = {}) {
addStructuredSystemMessage: vi.fn(),
setStatus: vi.fn(),
statusSummaryLines: () => [],
permissionDetails: () => [],
connectionDiagnosticDetails: () => [],
toolInventoryDetails: vi.fn(() => []),
modelStatusLines: () => [],

View file

@ -45,6 +45,7 @@ function turnBundleFixture(options: { stateStore?: ReturnType<typeof createChatS
modelStatusLines: vi.fn(() => []),
effortStatusLines: vi.fn(() => []),
statusSummaryLines: vi.fn(() => []),
permissionDetails: vi.fn(() => []),
toolInventoryDetails: vi.fn(() => [{ title: "Tool providers", auditFacts: [{ key: "codex_apps", value: "github, gmail" }] }]),
};
const refreshDiagnostics = vi.fn().mockResolvedValue(undefined);

View file

@ -25,6 +25,7 @@ describe("createChatPanelRuntimeProjection", () => {
state: () => state,
connected: () => true,
configuredCommand: () => "codex",
vaultPath: () => "/vault",
nowMs: () => 0,
});
@ -33,6 +34,52 @@ describe("createChatPanelRuntimeProjection", () => {
expect(projection.modelStatusLines()).toContain("Mode: Default");
expect(projection.effortStatusLines()).toContain("Supported: high");
});
it("builds slash-command permission details from chat state", () => {
const state = chatStateWith(chatStateFixture(), {
activeThread: { id: "thread-1" },
runtime: {
active: {
activePermissionProfile: { id: "workspace-write", extends: null },
sandboxPolicy: {
type: "workspaceWrite",
writableRoots: ["/vault/Notes"],
networkAccess: false,
excludeTmpdirEnvVar: false,
excludeSlashTmp: false,
},
approvalPolicy: "on-request",
approvalsReviewer: "auto_review",
},
},
});
const projection = createChatPanelRuntimeProjection({
state: () => state,
connected: () => true,
configuredCommand: () => "codex",
vaultPath: () => "/vault",
nowMs: () => 0,
});
expect(projection.permissionDetails()).toEqual([
{
title: "Permissions",
auditFacts: [
{ key: "Profile", value: "workspace-write" },
{ key: "Sandbox", value: "workspace-write" },
{ key: "Network", value: "blocked" },
{ key: "Extra writable roots", value: "Vault/Notes" },
],
},
{
title: "Approvals",
auditFacts: [
{ key: "Approval policy", value: "on-request" },
{ key: "Reviewer", value: "auto_review" },
],
},
]);
});
});
function runtimeConfigFixture(config: Record<string, unknown>): RuntimeConfigSnapshot {