一键导入
mynah-ui-guide
How to use mynah-ui correctly in the gissyphus web UI. Activate this skill before modifying web/src/main.ts or working with the chat interface.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
菜单
How to use mynah-ui correctly in the gissyphus web UI. Activate this skill before modifying web/src/main.ts or working with the chat interface.
用 Codex 或 Claude 帮你安装 复制这段 Prompt,粘贴到 Codex、Claude 或其他助手里,让它检查 Skill 页面并帮你完成安装。
基于 SOC 职业分类
| name | mynah-ui-guide |
| description | How to use mynah-ui correctly in the gissyphus web UI. Activate this skill before modifying web/src/main.ts or working with the chat interface. |
mynah-ui (@aws/mynah-ui) is the chat UI library used by the gissyphus web frontend. It manages its own DOM — it is NOT React.
// ❌ BROKEN — silently breaks all subsequent addChatItem calls
mynahUI.updateStore(tabId, { chatItems: [...] });
// ✅ CORRECT — always add items individually
mynahUI.addChatItem(tabId, { type: ChatItemType.ANSWER, body: "hello" });
updateStore({ chatItems }) corrupts internal DOM state. After this call, addChatItem silently appends to a detached node — nothing appears, no error is thrown.
Use updateStore only for non-chatItems fields: loadingChat, tabTitle, promptInputPlaceholder, contextCommands, etc.
mynah-ui is a UMD bundle. Use default import and destructure:
import MynahUIExports from "@aws/mynah-ui";
const { MynahUI, ChatItemType } = MynahUIExports;
import type { ChatPrompt } from "@aws/mynah-ui";
Create tabs in the constructor config. Don't put chatItems in the initial store — add them via addChatItem after construction:
const mynahUI = new MynahUI({
loadStyles: true,
rootSelector: "#gissyphus",
tabs: {
"main": {
isSelected: true,
store: { tabTitle: "My App", promptInputPlaceholder: "Ask..." },
},
},
onChatPrompt(tabId, prompt) { ... },
onInBodyButtonClicked(tabId, messageId, action) { ... },
});
// Add initial content AFTER construction
mynahUI.addChatItem("main", { type: ChatItemType.ANSWER, body: "Ready." });
The UI must track what kind of content is currently streaming. Adding any chat item (tool card, permission card, error) ends the active stream internally. Without explicit tracking, subsequent text chunks go nowhere.
let currentMessageId: string | null = null;
let currentStreamType: "text" | "tool" | null = null;
let streamingBody = "";
Rules:
currentStreamType and currentMessageId when starting any streamfinally block when a prompt completesstreamingBody when starting a new text segmentUse ANSWER_STREAM + updateLastChatAnswer. Accumulate the full body and replace on each chunk (mynah-ui replaces, not appends):
mynahUI.addChatItem(tabId, { type: ChatItemType.ANSWER_STREAM, messageId: id, body: "" });
// On each chunk:
streamingBody += chunk;
mynahUI.updateLastChatAnswer(tabId, { body: streamingBody });
// When the prompt completes:
mynahUI.endMessageStream(tabId, messageId);
Use ensureTextStream() to lazily create a text stream — it's a no-op if one is already active, and creates a new ANSWER_STREAM if the current stream is null or a tool card:
function ensureTextStream() {
if (currentStreamType === "text" && currentMessageId) return;
streamingBody = "";
currentMessageId = nextId("text");
currentStreamType = "text";
mynahUI.addChatItem(tabId, { type: ChatItemType.ANSWER_STREAM, messageId: currentMessageId, body: "" });
}
Use ANSWER_STREAM for new tool calls. Track toolCallId → messageId to update existing cards:
// New tool call — becomes the active stream
const id = nextId("tool");
toolCallMessageIds[tc.toolCallId] = id;
currentMessageId = id;
currentStreamType = "tool";
mynahUI.addChatItem(tabId, { type: ChatItemType.ANSWER_STREAM, messageId: id, body: "🔧 ..." });
// Update existing tool call (works regardless of which card is "last")
mynahUI.updateChatAnswerWithMessageId(tabId, messageId, { body: "🔧 ...", status: "success" });
Use ANSWER (not ANSWER_STREAM) with buttons. Null out stream state so ensureTextStream() creates a fresh stream when text resumes:
mynahUI.addChatItem(tabId, {
type: ChatItemType.ANSWER,
messageId: msgId,
body: "**Permission requested:** ...",
buttons: [
{ id: optionId, text: "Allow", status: "success", keepCardAfterClick: false },
{ id: optionId, text: "Deny", status: "error", keepCardAfterClick: false },
],
});
currentMessageId = null;
currentStreamType = null;
Resolve via onInBodyButtonClicked using a Map<messageId, resolve>:
onInBodyButtonClicked(tabId, messageId, action) {
const resolve = pendingPermissions.get(messageId);
if (resolve) {
pendingPermissions.delete(messageId);
resolve({ outcome: { outcome: "selected", optionId: action.id } });
}
}
Guard sendPrompt with a promptPending flag. The ACP proxy may fire an initial prompt on session start (server-side), and user prompts must not overlap:
let promptPending = false;
async function sendPrompt(prompt) {
if (!connection || !sessionId || promptPending) return;
promptPending = true;
try { ... } finally { promptPending = false; }
}
| Method | Use for |
|---|---|
addChatItem(tabId, item) | Adding any new card |
updateLastChatAnswer(tabId, partial) | Updating the active streaming card's content |
updateChatAnswerWithMessageId(tabId, msgId, partial) | Updating a specific card by ID |
endMessageStream(tabId, msgId, partial?) | Finalizing a completed stream |
updateStore(tabId, data) | Non-chatItems store fields ONLY |
getAllTabs() | Debugging — check which tabs exist |
cd web && npm run build # tsc + vite → web/dist/
cd .. && cargo build # embeds web/dist/ via rust-embed
Frontend must be built before cargo build. The Rust binary embeds web/dist/ at compile time.
cargo run -- serve --debug injects window.GISSYPHUS_DEBUG=true into the served HTML. Vite dev mode enables logs automatically. Gate debug logs on:
const DEBUG = window.GISSYPHUS_DEBUG === true || import.meta.env.DEV;