diff --git a/.mcp.json b/.mcp.json
index 93bc19c..ec1ba23 100644
--- a/.mcp.json
+++ b/.mcp.json
@@ -8,16 +8,6 @@
"chrome-devtools-mcp@latest"
],
"env": {}
- },
- "feishu": {
- "command": "node",
- "args": ["packages/feishu-mcp/dist/index.js"],
- "env": {
- "FEISHU_APP_ID": "${bridge_feishu_app_id}",
- "FEISHU_APP_SECRET": "${bridge_feishu_app_secret}",
- "FEISHU_DOMAIN": "${bridge_feishu_domain}",
- "FEISHU_USER_ACCESS_TOKEN": "${bridge_feishu_user_token}"
- }
}
}
}
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 16e3220..6110782 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -2132,10 +2132,6 @@
"integrity": "sha512-YslZMgtJUyuMbZ+aKvfF3x1f5liK4mWNxghFRv7jqRR9C3R3fAOGTTKvxXDa2Y1s9zSbcpuO0cAxDYsc9SrXoQ==",
"license": "Apache-2.0"
},
- "node_modules/@codepilot/feishu-mcp": {
- "resolved": "packages/feishu-mcp",
- "link": true
- },
"node_modules/@codepilot/site": {
"resolved": "apps/site",
"link": true
@@ -27215,161 +27211,6 @@
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
- },
- "packages/feishu-mcp": {
- "name": "@codepilot/feishu-mcp",
- "version": "0.1.0",
- "dependencies": {
- "@anthropic-ai/sdk": "^0.39.0"
- },
- "bin": {
- "feishu-mcp": "dist/index.js"
- },
- "devDependencies": {
- "tsup": "^8.0.0",
- "tsx": "^4.0.0"
- }
- },
- "packages/feishu-mcp/node_modules/postcss-load-config": {
- "version": "6.0.1",
- "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz",
- "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==",
- "dev": true,
- "funding": [
- {
- "type": "opencollective",
- "url": "https://opencollective.com/postcss/"
- },
- {
- "type": "github",
- "url": "https://github.com/sponsors/ai"
- }
- ],
- "license": "MIT",
- "dependencies": {
- "lilconfig": "^3.1.1"
- },
- "engines": {
- "node": ">= 18"
- },
- "peerDependencies": {
- "jiti": ">=1.21.0",
- "postcss": ">=8.0.9",
- "tsx": "^4.8.1",
- "yaml": "^2.4.2"
- },
- "peerDependenciesMeta": {
- "jiti": {
- "optional": true
- },
- "postcss": {
- "optional": true
- },
- "tsx": {
- "optional": true
- },
- "yaml": {
- "optional": true
- }
- }
- },
- "packages/feishu-mcp/node_modules/resolve-from": {
- "version": "5.0.0",
- "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz",
- "integrity": "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=8"
- }
- },
- "packages/feishu-mcp/node_modules/source-map": {
- "version": "0.7.6",
- "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz",
- "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==",
- "dev": true,
- "license": "BSD-3-Clause",
- "engines": {
- "node": ">= 12"
- }
- },
- "packages/feishu-mcp/node_modules/tinyexec": {
- "version": "0.3.2",
- "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz",
- "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==",
- "dev": true,
- "license": "MIT"
- },
- "packages/feishu-mcp/node_modules/tsup": {
- "version": "8.5.1",
- "resolved": "https://registry.npmjs.org/tsup/-/tsup-8.5.1.tgz",
- "integrity": "sha512-xtgkqwdhpKWr3tKPmCkvYmS9xnQK3m3XgxZHwSUjvfTjp7YfXe5tT3GgWi0F2N+ZSMsOeWeZFh7ZZFg5iPhing==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "bundle-require": "^5.1.0",
- "cac": "^6.7.14",
- "chokidar": "^4.0.3",
- "consola": "^3.4.0",
- "debug": "^4.4.0",
- "esbuild": "^0.27.0",
- "fix-dts-default-cjs-exports": "^1.0.0",
- "joycon": "^3.1.1",
- "picocolors": "^1.1.1",
- "postcss-load-config": "^6.0.1",
- "resolve-from": "^5.0.0",
- "rollup": "^4.34.8",
- "source-map": "^0.7.6",
- "sucrase": "^3.35.0",
- "tinyexec": "^0.3.2",
- "tinyglobby": "^0.2.11",
- "tree-kill": "^1.2.2"
- },
- "bin": {
- "tsup": "dist/cli-default.js",
- "tsup-node": "dist/cli-node.js"
- },
- "engines": {
- "node": ">=18"
- },
- "peerDependencies": {
- "@microsoft/api-extractor": "^7.36.0",
- "@swc/core": "^1",
- "postcss": "^8.4.12",
- "typescript": ">=4.5.0"
- },
- "peerDependenciesMeta": {
- "@microsoft/api-extractor": {
- "optional": true
- },
- "@swc/core": {
- "optional": true
- },
- "postcss": {
- "optional": true
- },
- "typescript": {
- "optional": true
- }
- }
- },
- "packages/feishu-mcp/node_modules/yaml": {
- "version": "2.8.2",
- "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz",
- "integrity": "sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A==",
- "dev": true,
- "license": "ISC",
- "optional": true,
- "peer": true,
- "bin": {
- "yaml": "bin.mjs"
- },
- "engines": {
- "node": ">= 14.6"
- },
- "funding": {
- "url": "https://github.com/sponsors/eemeli"
- }
}
}
}
diff --git a/src/lib/bridge/bridge-manager.ts b/src/lib/bridge/bridge-manager.ts
index 5d632c2..6398b80 100644
--- a/src/lib/bridge/bridge-manager.ts
+++ b/src/lib/bridge/bridge-manager.ts
@@ -540,6 +540,8 @@ async function handleMessage(
let cardCreating = false;
let cardBufferedText = '';
let cardFinalized = false;
+ /** Track tool calls for card progress display */
+ const cardToolCalls: import('../channels/types').ToolCallInfo[] = [];
if (!previewState && adapter.getCardStreamController) {
cardController = adapter.getCardStreamController();
@@ -619,6 +621,23 @@ async function handleMessage(
};
}
+ // Build onToolEvent callback for card tool progress
+ let onToolEvent: ((event: any) => void) | undefined;
+ if (cardController) {
+ onToolEvent = (event: any) => {
+ if (event.type === 'tool_use') {
+ cardToolCalls.push({ id: event.id, name: event.name, status: 'running' });
+ } else if (event.type === 'tool_result') {
+ const tc = cardToolCalls.find((t) => t.id === event.tool_use_id);
+ if (tc) tc.status = event.is_error ? 'error' : 'complete';
+ }
+ // Update card display if we have a message ID
+ if (cardMessageId && cardController?.updateToolCalls) {
+ cardController.updateToolCalls(cardMessageId, cardToolCalls);
+ }
+ };
+ }
+
try {
// Pass permission callback so requests are forwarded to IM immediately
// during streaming (the stream blocks until permission is resolved).
@@ -636,25 +655,31 @@ async function handleMessage(
perm.suggestions,
msg.messageId,
);
- }, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText);
+ }, taskAbort.signal, hasAttachments ? msg.attachments : undefined, onPartialText, onToolEvent);
// Send response text — render via channel-appropriate format
if (result.responseText) {
if (cardController && cardMessageId) {
// Finalize streaming card with final content
- await cardController.finalize(cardMessageId, result.responseText);
+ const finalStatus = result.hasError ? 'error' : 'completed';
+ await cardController.finalize(cardMessageId, result.responseText, finalStatus);
cardFinalized = true;
} else {
await deliverResponse(adapter, msg.address, result.responseText, binding.codepilotSessionId, msg.messageId);
}
} else if (result.hasError) {
- const errorResponse: OutboundMessage = {
- address: msg.address,
- text: `Error: ${escapeHtml(result.errorMessage)}`,
- parseMode: 'HTML',
- replyToMessageId: msg.messageId,
- };
- await deliver(adapter, errorResponse);
+ if (cardController && cardMessageId) {
+ await cardController.finalize(cardMessageId, `❌ Error: ${result.errorMessage}`, 'error');
+ cardFinalized = true;
+ } else {
+ const errorResponse: OutboundMessage = {
+ address: msg.address,
+ text: `Error: ${escapeHtml(result.errorMessage)}`,
+ parseMode: 'HTML',
+ replyToMessageId: msg.messageId,
+ };
+ await deliver(adapter, errorResponse);
+ }
}
// Persist the actual SDK session ID for future resume.
@@ -682,7 +707,7 @@ async function handleMessage(
// Clean up card streaming state
if (cardController && cardMessageId && !cardFinalized) {
- cardController.finalize(cardMessageId, '⚠️ Response interrupted.').catch(() => {});
+ cardController.finalize(cardMessageId, '⚠️ Response interrupted.', 'interrupted').catch(() => {});
}
state.activeTasks.delete(binding.codepilotSessionId);
@@ -735,20 +760,7 @@ async function handleCommand(
'CodePilot Bridge',
'',
'Send any message to interact with Claude.',
- '',
- 'Commands:',
- '/new [path] - Start new session',
- '/bind <session_id> - Bind to existing session',
- '/cwd /path - Change working directory',
- '/mode plan|code|ask - Change mode',
- '/status - Show current status',
- '/sessions - List recent sessions',
- '/stop - Stop current session',
- '/perm allow|allow_session|deny <id> - Respond to permission',
- '/history [count] - Show recent messages',
- '/search <keyword> - Search messages',
- '/feishu <subcommand> - Feishu bridge management',
- '/help - Show this help',
+ 'Type /help for available commands.',
].join('\n');
break;
@@ -1109,18 +1121,23 @@ async function handleCommand(
response = [
'CodePilot Bridge Commands',
'',
- '/new [path] - Start new session',
+ 'Session:',
+ '/new [path] - Create new session (optional: specify CWD)',
+ '/cwd /path - Change CWD, reset context',
'/bind <session_id> - Bind to existing session',
- '/cwd /path - Change working directory',
'/mode plan|code|ask - Change mode',
- '/status - Show current status',
+ '/status - Show session / CWD / mode / model',
'/sessions - List recent sessions',
- '/stop - Stop current session',
- '/perm allow|allow_session|deny <id> - Respond to permission request',
+ '/stop - Stop current task',
+ '',
+ 'Messages:',
'/history [count] - Show recent messages',
- '/search <keyword> - Search messages',
- '/feishu <subcommand> - Feishu bridge management',
- '/help - Show this help',
+ '/search <keyword> - Search in current chat',
+ '/perm allow|deny <id> - Permission response',
+ '',
+ 'Feishu:',
+ '/feishu doctor - Run diagnostics',
+ '/feishu auth - View auth status',
].join('\n');
break;
diff --git a/src/lib/bridge/conversation-engine.ts b/src/lib/bridge/conversation-engine.ts
index 689a06c..005b31e 100644
--- a/src/lib/bridge/conversation-engine.ts
+++ b/src/lib/bridge/conversation-engine.ts
@@ -83,6 +83,12 @@ export type OnPermissionRequest = (perm: PermissionRequestInfo) => Promise
*/
export type OnPartialText = (fullText: string) => void;
+/**
+ * Callback invoked on tool_use / tool_result SSE events.
+ * Used by card streaming to show tool progress indicators.
+ */
+export type OnToolEvent = (event: { type: 'tool_use'; id: string; name: string } | { type: 'tool_result'; tool_use_id: string; is_error: boolean }) => void;
+
export interface ConversationResult {
responseText: string;
tokenUsage: TokenUsage | null;
@@ -105,6 +111,7 @@ export async function processMessage(
abortSignal?: AbortSignal,
files?: FileAttachment[],
onPartialText?: OnPartialText,
+ onToolEvent?: OnToolEvent,
): Promise {
const sessionId = binding.codepilotSessionId;
@@ -232,7 +239,7 @@ export async function processMessage(
// Consume the stream server-side (replicate collectStreamResponse pattern).
// Permission requests are forwarded immediately via the callback during streaming
// because the stream blocks until permission is resolved — we can't wait until after.
- return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText);
+ return await consumeStream(stream, sessionId, onPermissionRequest, onPartialText, onToolEvent);
} finally {
clearInterval(renewalInterval);
releaseSessionLock(sessionId, lockId);
@@ -249,6 +256,7 @@ async function consumeStream(
sessionId: string,
onPermissionRequest?: OnPermissionRequest,
onPartialText?: OnPartialText,
+ onToolEvent?: OnToolEvent,
): Promise {
const reader = stream.getReader();
const contentBlocks: MessageContentBlock[] = [];
@@ -300,6 +308,9 @@ async function consumeStream(
name: toolData.name,
input: toolData.input,
});
+ if (onToolEvent) {
+ try { onToolEvent({ type: 'tool_use', id: toolData.id, name: toolData.name }); } catch { /* non-critical */ }
+ }
} catch { /* skip */ }
break;
}
@@ -322,6 +333,9 @@ async function consumeStream(
seenToolResultIds.add(resultData.tool_use_id);
contentBlocks.push(newBlock);
}
+ if (onToolEvent) {
+ try { onToolEvent({ type: 'tool_result', tool_use_id: resultData.tool_use_id, is_error: resultData.is_error || false }); } catch { /* non-critical */ }
+ }
} catch { /* skip */ }
break;
}
diff --git a/src/lib/channels/feishu/card-controller.ts b/src/lib/channels/feishu/card-controller.ts
index 9cedb8d..46a6107 100644
--- a/src/lib/channels/feishu/card-controller.ts
+++ b/src/lib/channels/feishu/card-controller.ts
@@ -1,22 +1,21 @@
/**
- * CodePilot Simplified Card Streaming
+ * Feishu Card Streaming Controller
*
* Manages streaming card lifecycle via CardKit v2 API.
- * State machine: idle → creating → streaming → completed | error
+ * State machine: idle → creating → streaming → completed | interrupted | error
*
- * Differences from OpenClaw's StreamingCardController:
- * - No reasoning phase streaming (thinking indicator)
- * - No FlushController / UnavailableGuard
- * - No IM patch fallback (if CardKit fails, falls back to static text)
- * - No reply boundary detection
- * - Footer is simplified (just elapsed time and status text, no animated status)
- *
- * These are intentional simplifications for CodePilot's use case.
+ * Features:
+ * - Thinking state display (💭 Thinking...)
+ * - Streaming text with throttled updates
+ * - Tool call progress indicators (🔄/✅/❌)
+ * - Final card with status footer and elapsed time
+ * - Markdown optimization for Feishu rendering
*/
import type * as lark from '@larksuiteoapi/node-sdk';
-import type { CardStreamController } from '../types';
+import type { CardStreamController, ToolCallInfo } from '../types';
import type { CardStreamConfig } from './types';
+import { optimizeMarkdown } from './outbound';
const LOG_TAG = '[card-controller]';
@@ -28,6 +27,30 @@ interface CardState {
startTime: number;
throttleTimer: ReturnType | null;
pendingText: string | null;
+ /** Current tool calls being tracked */
+ toolCalls: ToolCallInfo[];
+ /** Whether we're in thinking state (before text starts flowing) */
+ thinking: boolean;
+}
+
+/** Format elapsed time for footer display */
+function formatElapsed(ms: number): string {
+ if (ms < 1000) return `${ms}ms`;
+ const sec = ms / 1000;
+ if (sec < 60) return `${sec.toFixed(1)}s`;
+ const min = Math.floor(sec / 60);
+ const remSec = Math.floor(sec % 60);
+ return `${min}m ${remSec}s`;
+}
+
+/** Build tool progress lines for card display */
+function buildToolProgressMarkdown(tools: ToolCallInfo[]): string {
+ if (tools.length === 0) return '';
+ const lines = tools.map((tc) => {
+ const icon = tc.status === 'running' ? '🔄' : tc.status === 'complete' ? '✅' : '❌';
+ return `${icon} \`${tc.name}\``;
+ });
+ return lines.join('\n');
}
class FeishuCardStreamController implements CardStreamController {
@@ -45,11 +68,17 @@ class FeishuCardStreamController implements CardStreamController {
// 1. Create streaming card via CardKit v2
const cardBody = {
schema: '2.0',
- config: { streaming_mode: true, wide_screen_mode: true },
+ config: {
+ streaming_mode: true,
+ wide_screen_mode: true,
+ summary: { content: '思考中...' },
+ },
body: {
elements: [{
tag: 'markdown',
- content: initialText || '...',
+ content: initialText || '💭 Thinking...',
+ text_align: 'left',
+ text_size: 'normal',
element_id: 'streaming_content',
}],
},
@@ -63,7 +92,6 @@ class FeishuCardStreamController implements CardStreamController {
console.error(LOG_TAG, 'Card create returned no card_id');
return '';
}
- console.log(LOG_TAG, 'Created card:', cardId);
// 2. Send card as IM message
const cardContent = JSON.stringify({ type: 'card', data: { card_id: cardId } });
@@ -80,7 +108,6 @@ class FeishuCardStreamController implements CardStreamController {
});
}
const messageId = msgResp?.data?.message_id || '';
- console.log(LOG_TAG, 'Sent card message:', messageId);
this.cards.set(messageId, {
cardId,
@@ -90,6 +117,8 @@ class FeishuCardStreamController implements CardStreamController {
startTime: Date.now(),
throttleTimer: null,
pendingText: null,
+ toolCalls: [],
+ thinking: !initialText,
});
return messageId;
@@ -103,6 +132,11 @@ class FeishuCardStreamController implements CardStreamController {
const state = this.cards.get(messageId);
if (!state) return 'fail';
+ // Clear thinking state once text starts flowing
+ if (state.thinking && text.trim()) {
+ state.thinking = false;
+ }
+
state.pendingText = text;
const elapsed = Date.now() - state.lastUpdateAt;
@@ -123,15 +157,21 @@ class FeishuCardStreamController implements CardStreamController {
}
private async flushUpdate(state: CardState): Promise<'ok' | 'fail'> {
- if (!state.pendingText) return 'ok';
- const text = state.pendingText;
+ if (!state.pendingText && state.toolCalls.length === 0) return 'ok';
+
+ // Build content: main text + tool progress
+ let content = state.pendingText || '';
+ const toolMd = buildToolProgressMarkdown(state.toolCalls);
+ if (toolMd) {
+ content = content ? `${content}\n\n${toolMd}` : toolMd;
+ }
state.pendingText = null;
try {
state.sequence++;
await (this.client as any).cardkit.v2.card.streamContent({
path: { card_id: state.cardId },
- data: { content: text, sequence: state.sequence },
+ data: { content, sequence: state.sequence },
});
state.lastUpdateAt = Date.now();
return 'ok';
@@ -141,6 +181,31 @@ class FeishuCardStreamController implements CardStreamController {
}
}
+ /** Update tool call progress — triggers a card update */
+ updateToolCalls(messageId: string, tools: ToolCallInfo[]): void {
+ const state = this.cards.get(messageId);
+ if (!state) return;
+ state.toolCalls = tools;
+
+ // Force a flush with current text + updated tool progress
+ const elapsed = Date.now() - state.lastUpdateAt;
+ if (elapsed >= this.config.throttleMs) {
+ this.flushUpdate(state).catch(() => {});
+ } else if (!state.throttleTimer) {
+ state.throttleTimer = setTimeout(() => {
+ state.throttleTimer = null;
+ this.flushUpdate(state).catch(() => {});
+ }, this.config.throttleMs - elapsed);
+ }
+ }
+
+ /** Set thinking state — shows 💭 Thinking... in card */
+ setThinking(messageId: string): void {
+ const state = this.cards.get(messageId);
+ if (!state) return;
+ state.thinking = true;
+ }
+
async finalize(
messageId: string,
finalText: string,
@@ -157,62 +222,80 @@ class FeishuCardStreamController implements CardStreamController {
try {
// Close streaming mode
+ state.sequence++;
await (this.client as any).cardkit.v2.card.setStreamingMode({
path: { card_id: state.cardId },
- params: { streaming_mode: false },
+ data: { streaming_mode: false, sequence: state.sequence },
});
- // Build footer elements if configured
- const footerElements: Array<{ tag: string; content: string; element_id: string }> = [];
- const footerCfg = this.config.footer;
+ // Build final card elements
+ const elements: any[] = [];
- if (footerCfg?.elapsed) {
- const elapsedMs = Date.now() - state.startTime;
- const elapsedSec = (elapsedMs / 1000).toFixed(1);
- footerElements.push({
- tag: 'markdown',
- content: `*${elapsedSec}s*`,
- element_id: 'footer_elapsed',
- });
+ // Main content (optimize markdown for Feishu rendering)
+ elements.push({
+ tag: 'markdown',
+ content: optimizeMarkdown(finalText),
+ text_size: 'normal',
+ element_id: 'streaming_content',
+ });
+
+ // Tool call summary (if any tools were used)
+ if (state.toolCalls.length > 0) {
+ const toolMd = buildToolProgressMarkdown(state.toolCalls);
+ if (toolMd) {
+ elements.push({
+ tag: 'markdown',
+ content: toolMd,
+ text_size: 'notation',
+ element_id: 'tool_summary',
+ });
+ }
}
+ // Footer
+ const footerCfg = this.config.footer;
+ const footerParts: string[] = [];
+
if (footerCfg?.status) {
const statusLabels: Record = {
- completed: 'Completed',
- interrupted: 'Interrupted',
- error: 'Error',
+ completed: '✅ Completed',
+ interrupted: '⚠️ Interrupted',
+ error: '❌ Error',
};
- footerElements.push({
+ footerParts.push(statusLabels[status] || status);
+ }
+
+ if (footerCfg?.elapsed) {
+ const elapsedMs = Date.now() - state.startTime;
+ footerParts.push(formatElapsed(elapsedMs));
+ }
+
+ if (footerParts.length > 0) {
+ elements.push({ tag: 'hr' });
+ elements.push({
tag: 'markdown',
- content: `**${statusLabels[status] || status}**`,
- element_id: 'footer_status',
+ content: footerParts.join(' · '),
+ text_size: 'notation',
+ element_id: 'footer',
});
}
- // Update final card content
+ // Update final card
const finalCard = {
schema: '2.0',
config: { wide_screen_mode: true },
- body: {
- elements: [
- {
- tag: 'markdown',
- content: finalText,
- element_id: 'streaming_content',
- },
- ...footerElements,
- ],
- },
+ body: { elements },
};
+
+ state.sequence++;
await (this.client as any).cardkit.v2.card.update({
path: { card_id: state.cardId },
- data: { type: 'card_json', data: JSON.stringify(finalCard) },
+ data: { type: 'card_json', data: JSON.stringify(finalCard), sequence: state.sequence },
});
} catch (err: any) {
console.error(LOG_TAG, 'Finalize failed:', err?.message || err);
}
- console.log(LOG_TAG, `Finalized card ${state.cardId} as ${status}`);
this.cards.delete(messageId);
}
}
diff --git a/src/lib/channels/feishu/index.ts b/src/lib/channels/feishu/index.ts
index 9964baa..4449750 100644
--- a/src/lib/channels/feishu/index.ts
+++ b/src/lib/channels/feishu/index.ts
@@ -206,7 +206,7 @@ export class FeishuChannelPlugin implements ChannelPlugin {
const messageId = this.lastMessageIdByChat.get(chatId);
if (!client || !messageId) return;
// Fire-and-forget — don't block message processing
- addReaction(client, messageId, 'OnIt').then((reactionId) => {
+ addReaction(client, messageId, 'Typing').then((reactionId) => {
if (reactionId) {
this.activeReactions.set(chatId, { messageId, reactionId });
}
diff --git a/src/lib/channels/feishu/outbound.ts b/src/lib/channels/feishu/outbound.ts
index fb2520b..1ba0724 100644
--- a/src/lib/channels/feishu/outbound.ts
+++ b/src/lib/channels/feishu/outbound.ts
@@ -25,7 +25,7 @@ const LOG_TAG = '[feishu/outbound]';
* - Strip invalid image keys (prevent CardKit error 200570)
* - Compress excessive newlines (3+ → 2)
*/
-function optimizeMarkdown(text: string): string {
+export function optimizeMarkdown(text: string): string {
try {
return _optimizeMarkdown(text);
} catch {
diff --git a/src/lib/channels/types.ts b/src/lib/channels/types.ts
index 5473f48..0a8b459 100644
--- a/src/lib/channels/types.ts
+++ b/src/lib/channels/types.ts
@@ -10,6 +10,13 @@ import type { ChannelType, InboundMessage, OutboundMessage, SendResult } from '.
// ── Card Stream Controller ──────────────────────────────────────
/** Controls a streaming card lifecycle (create → update → finalize). */
+/** Tool call info for card display */
+export interface ToolCallInfo {
+ id: string;
+ name: string;
+ status: 'running' | 'complete' | 'error';
+}
+
export interface CardStreamController {
/**
* Create a streaming card in the given chat.
@@ -31,6 +38,12 @@ export interface CardStreamController {
* @param status 'completed' | 'interrupted' | 'error'
*/
finalize(messageId: string, finalText: string, status?: 'completed' | 'interrupted' | 'error'): Promise;
+
+ /** Optional: update tool call progress display */
+ updateToolCalls?(messageId: string, tools: ToolCallInfo[]): void;
+
+ /** Optional: set thinking state before text starts flowing */
+ setThinking?(messageId: string): void;
}
// ── Channel Capabilities ────────────────────────────────────────