| 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 Usage Guide
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.
Critical Rule: Never Replace chatItems via updateStore
mynahUI.updateStore(tabId, { chatItems: [...] });
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.
Import Pattern
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";
Tab Initialization
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) { ... },
});
mynahUI.addChatItem("main", { type: ChatItemType.ANSWER, body: "Ready." });
Stream State Tracking
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:
- Set
currentStreamType and currentMessageId when starting any stream
- Null both out when adding a non-stream card (permission, error)
- Null both out in the
finally block when a prompt completes
- Reset
streamingBody when starting a new text segment
Streaming Text
Use 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: "" });
streamingBody += chunk;
mynahUI.updateLastChatAnswer(tabId, { body: streamingBody });
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: "" });
}
Tool Call Cards
Use ANSWER_STREAM for new tool calls. Track toolCallId → messageId to update existing cards:
const id = nextId("tool");
toolCallMessageIds[tc.toolCallId] = id;
currentMessageId = id;
currentStreamType = "tool";
mynahUI.addChatItem(tabId, { type: ChatItemType.ANSWER_STREAM, messageId: id, body: "🔧 ..." });
mynahUI.updateChatAnswerWithMessageId(tabId, messageId, { body: "🔧 ...", status: "success" });
Permission / Approval Cards
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 } });
}
}
Preventing Concurrent Prompts
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; }
}
Key API Methods
| 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 |
Build Workflow
cd web && npm run build
cd .. && cargo build
Frontend must be built before cargo build. The Rust binary embeds web/dist/ at compile time.
Debug Logging
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;