Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 24 additions & 3 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,8 @@ export type ChatMessage =
}
| { kind: "status"; text: string }
| { kind: "warning"; id: string; text: string; severity: "low" | "high" }
| { kind: "error"; message: string };
| { kind: "error"; message: string }
| { kind: "info"; message: string };

export type PendingConfirm = {
id: number;
Expand Down Expand Up @@ -345,7 +346,7 @@ function reduceRaw(state: State, action: Action): State {
...state.messages,
{
kind: "user",
text: `/${action.skill.name}${argsLine}`,
text: `/skill ${action.skill.name}${argsLine}`,
clientId: action.clientId,
turn: nextMessageTurn(state.messages),
skill: action.skill,
Expand Down Expand Up @@ -923,6 +924,11 @@ function applyIncomingRaw(state: State, ev: IncomingEvent): State {
activeSkill: null,
messages: [...state.messages, { kind: "error", message: ev.message }],
};
case "$info":
return {
...state,
messages: [...state.messages, { kind: "info", message: ev.message }],
};
case "model.turn.started":
if (state.messages.some((m) => m.kind === "assistant" && m.turn === ev.turn)) {
return { ...state, model: ev.model };
Expand Down Expand Up @@ -1103,6 +1109,7 @@ function formatConversationMarkdown(messages: ChatMessage[], userLabel: string):
return `### Reasonix\n\n${body}`;
}
if (m.kind === "error") return `### Error\n\n${m.message}`;
if (m.kind === "info") return `> ℹ ${m.message}`;
return "";
})
.filter(Boolean)
Expand Down Expand Up @@ -1781,7 +1788,7 @@ function TabRuntime({
},
},
...state.skills.map((s) => ({
cmd: `/${s.name}`,
cmd: `/skill ${s.name}`,
desc: s.description?.trim() || fallbackSkillDesc(s),
insertOnly: true,
run: () => {
Expand Down Expand Up @@ -2036,6 +2043,20 @@ function TabRuntime({
</div>
);
}
if (m.kind === "info") {
return (
<div
key={`info-${i}`}
className="warn-card"
style={{ opacity: 0.85, fontStyle: "italic" }}
>
<span className="ico" style={{ color: "var(--tone-info, #888)" }}>
<I.info size={16} />
</span>
<div className="ds">{m.message}</div>
</div>
);
}
if (m.kind === "warning") {
if (state.settings?.showSystemEvents === false) return null;
return (
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ export const I = {
shield: (p: IconProps) => (<Ic {...p}><path d="M12 3 4 6v6c0 5 3.5 8 8 9 4.5-1 8-4 8-9V6Z" /><path d="m9 12 2 2 4-4" /></Ic>),
warn: (p: IconProps) => (<Ic {...p}><path d="M12 3 2 21h20Z" /><path d="M12 10v5M12 18h.01" /></Ic>),
help: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="9" /><path d="M9.5 9a2.5 2.5 0 0 1 5 0c0 1.5-2.5 2-2.5 4M12 17h.01" /></Ic>),
info: (p: IconProps) => (<Ic {...p}><circle cx="12" cy="12" r="9" /><path d="M12 8h.01M12 12v4" /></Ic>),
refresh: (p: IconProps) => (<Ic {...p}><path d="M3 12a9 9 0 0 1 15-6.7L21 8M21 4v4h-4M21 12a9 9 0 0 1-15 6.7L3 16M3 20v-4h4" /></Ic>),
copy: (p: IconProps) => (<Ic {...p}><rect x="8" y="8" width="12" height="12" rx="2" /><path d="M4 16V6a2 2 0 0 1 2-2h10" /></Ic>),
};
Expand Down
32 changes: 27 additions & 5 deletions dashboard/src/lib/tauri-bridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,14 @@ function sseToIncoming(ev: any): Record<string, any>[] {
});
break;
}
case "info": {
results.push({
type: "$info",
tabId: "tab-1",
message: ev.text,
});
break;
}
case "status": {
results.push({
type: "status",
Expand Down Expand Up @@ -874,11 +882,25 @@ async function serverRpc(payload: Record<string, any>): Promise<void> {
break;
}
case "skill_run": {
const body: Record<string, any> = { name: payload.name };
if (payload.args) body.args = payload.args;
try {
await apiFetch("skills/run", { method: "POST", body: JSON.stringify(body) });
} catch {}
// Route through the submit endpoint so it goes through parseSlash →
// handleSlash → skill handler → resubmit, matching TUI behavior.
const skillText = payload.args
? `/skill ${payload.name} ${payload.args}`
: `/skill ${payload.name}`;
const result = await apiFetch("submit", {
method: "POST",
body: JSON.stringify({ prompt: skillText }),
}).catch((err) => {
console.warn("[tauri-bridge] skill_run submit failed:", err);
return null;
});
if (!result?.accepted) {
emitEvent({
type: "$error",
tabId: "tab-1",
message: result?.reason ?? "技能运行失败,请重试",
});
}
break;
}
case "mcp_specs_get": {
Expand Down
2 changes: 2 additions & 0 deletions dashboard/src/protocol.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export type ReadyEvent = { type: "$ready" };
export type ProtocolErrorEvent = { type: "$error"; message: string };
export type InfoEvent = { type: "$info"; message: string };
export type TurnCompleteEvent = { type: "$turn_complete" };
export type PathAccessRequiredEvent = {
type: "$path_access_required";
Expand Down Expand Up @@ -466,6 +467,7 @@ export type KernelErrorEvent = {
export type IncomingEvent = { tabId?: string } & (
| ReadyEvent
| ProtocolErrorEvent
| InfoEvent
| TurnCompleteEvent
| ConfirmRequiredEvent
| PathAccessRequiredEvent
Expand Down
8 changes: 8 additions & 0 deletions src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3230,6 +3230,14 @@ function AppInner({
if (fromQQ && result.info) qq.sendText(result.info);
if (fromTelegram && result.info) telegram.sendText(result.info);
if (fromWeixin && result.info) weixin.sendText(result.info);
// Bridge slash command info results to web dashboard subscribers
if (result.info && eventSubscribersRef.current.size > 0) {
broadcastDashboardEvent({
kind: "info",
id: `slash-info-${Date.now()}`,
text: result.info,
});
}
if (outcome.kind === "resubmit") {
text = outcome.text;
} else {
Expand Down
2 changes: 2 additions & 0 deletions src/cli/ui/effects/loop-to-dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ export function loopEventToDashboard(
return { kind: "error", id, text: ev.content };
case "status":
return { kind: "status", text: ev.content };
case "info":
return { kind: "info", id, text: ev.content };
case "steer":
return { kind: "user", id, text: ev.content };
default:
Expand Down
2 changes: 2 additions & 0 deletions src/loop/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export type EventRole =
| "warning"
/** Transient indicator for silent phases; UI clears on next primary event. */
| "status"
/** Informational message from slash commands or system feedback; bridged to dashboard via SSE. */
| "info"
/** Mid-turn steer injected as queued user guidance without aborting the current turn. */
| "steer";

Expand Down
50 changes: 29 additions & 21 deletions src/server/api/skills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,36 +156,44 @@ export async function handleSkills(

if (method === "GET" && rest.length === 0) {
const runs7d = countSubagentRuns(ctx.usageLogPath);
const tag = (rows: SkillListEntry[]) =>
rows.map((r) => ({ ...r, runs7d: runs7d.get(r.name) ?? 0 }));
const store = new SkillStore({
projectRoot: cwd,
customSkillPaths: loadResolvedSkillPaths(cwd ?? process.cwd(), ctx.configPath),
subagentModels: loadSubagentModels(ctx.configPath),
});
const customRoots = store.customRoots();
// Use SkillStore.list() so symlinks are resolved identically to TUI.
const all = store.list();
const toEntries = (scope: string) =>
all
.filter((s) => s.scope === scope)
.map((s) => ({
name: s.name,
scope: s.scope as SkillListEntry["scope"],
description: s.description,
path: s.path,
size: 0,
mtime: 0,
runAs: s.runAs,
model: s.model,
runs7d: runs7d.get(s.name) ?? 0,
}));
Comment thread
lanshi17 marked this conversation as resolved.
return {
status: 200,
body: {
global: tag(await listSkills(globalSkillsDir(), "global")),
custom: tag(
(await Promise.all(customRoots.map((root) => listSkills(root.dir, "custom")))).flat(),
),
project: cwd ? tag(await listSkills(projectSkillsDir(cwd), "project")) : [],
builtin: [
{
name: "explore",
scope: "builtin",
description: "subagent — broad codebase survey",
runs7d: runs7d.get("explore") ?? 0,
},
{
name: "research",
scope: "builtin",
description: "subagent — deep web + repo research",
runs7d: runs7d.get("research") ?? 0,
},
],
global: toEntries("global"),
custom: toEntries("custom"),
project: cwd ? toEntries("project") : [],
builtin: all
.filter((s) => s.scope === "builtin")
.map((s) => ({
name: s.name,
scope: "builtin" as const,
description: s.description,
runAs: s.runAs,
model: s.model,
runs7d: runs7d.get(s.name) ?? 0,
})),
paths: {
global: globalSkillsDir(),
project: cwd ? projectSkillsDir(cwd) : null,
Expand Down
12 changes: 12 additions & 0 deletions tests/loop-to-dashboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,18 @@ describe("loopEventToDashboard", () => {
});
});

it("converts info events to DashboardEvent kind=info", () => {
const result = loopEventToDashboard(
ev({ role: "info", content: "Unknown command: /xyz" }),
ctx,
);
expect(result).not.toBeNull();
expect(result!.kind).toBe("info");
if (result!.kind === "info") {
expect(result!.text).toBe("Unknown command: /xyz");
}
});

it("returns null for unrecognized roles", () => {
expect(loopEventToDashboard(ev({ role: "assistant_final" }), ctx)).toBeNull();
expect(loopEventToDashboard(ev({ role: "tool_call_delta" }), ctx)).toBeNull();
Expand Down