diff --git a/agent/claudecode/claudecode.go b/agent/claudecode/claudecode.go index 1b932e9..958cee1 100644 --- a/agent/claudecode/claudecode.go +++ b/agent/claudecode/claudecode.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "sort" "strings" "sync" @@ -326,12 +327,20 @@ func scanSessionMeta(path string) (string, int) { } } } + summary = stripXMLTags(summary) + summary = strings.TrimSpace(summary) if utf8.RuneCountInString(summary) > 40 { summary = string([]rune(summary)[:40]) + "..." } return summary, count } +var xmlTagRe = regexp.MustCompile(`<[^>]+>`) + +func stripXMLTags(s string) string { + return xmlTagRe.ReplaceAllString(s, "") +} + // GetSessionHistory reads the Claude Code JSONL transcript and returns user/assistant messages. func (a *Agent) GetSessionHistory(_ context.Context, sessionID string, limit int) ([]core.HistoryEntry, error) { homeDir, err := os.UserHomeDir() diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index 9683d5f..f6a02f1 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -247,12 +247,15 @@ func main() { spcfg.MinDeltaChars = *cfg.StreamPreview.MinDeltaChars } if cfg.StreamPreview.MaxChars != nil { - spcfg.MaxChars = *cfg.StreamPreview.MaxChars - } - engine.SetStreamPreviewCfg(spcfg) - } + spcfg.MaxChars = *cfg.StreamPreview.MaxChars + } + if cfg.StreamPreview.DisabledPlatforms != nil { + spcfg.DisabledPlatforms = cfg.StreamPreview.DisabledPlatforms + } + engine.SetStreamPreviewCfg(spcfg) + } - // Wire rate limiting + // Wire rate limiting { maxMsg := 20 windowSecs := 60 diff --git a/config/config.go b/config/config.go index 001ff37..2c6cb47 100644 --- a/config/config.go +++ b/config/config.go @@ -44,10 +44,11 @@ type DisplayConfig struct { // StreamPreviewConfig controls real-time streaming preview in IM. type StreamPreviewConfig struct { - Enabled *bool `toml:"enabled"` // default true - IntervalMs *int `toml:"interval_ms"` // min ms between updates; default 1500 - MinDeltaChars *int `toml:"min_delta_chars"` // min new chars before update; default 30 - MaxChars *int `toml:"max_chars"` // max preview length; default 2000 + Enabled *bool `toml:"enabled"` // default true + DisabledPlatforms []string `toml:"disabled_platforms,omitempty"` // platforms where preview is disabled (e.g. ["feishu"]) + IntervalMs *int `toml:"interval_ms"` // min ms between updates; default 1500 + MinDeltaChars *int `toml:"min_delta_chars"` // min new chars before update; default 30 + MaxChars *int `toml:"max_chars"` // max preview length; default 2000 } // RateLimitConfig controls per-session message rate limiting. diff --git a/core/engine.go b/core/engine.go index 4f4a814..384460b 100644 --- a/core/engine.go +++ b/core/engine.go @@ -972,7 +972,15 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess if !quiet { sp.freeze() inputPreview := truncateIf(event.ToolInput, e.display.ToolMaxLen) - e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgTool), toolCount, event.ToolName, inputPreview)) + // Use code block if content is long (>5 lines or >200 chars), otherwise inline code + lineCount := strings.Count(inputPreview, "\n") + 1 + var formattedInput string + if lineCount > 5 || utf8.RuneCountInString(inputPreview) > 200 { + formattedInput = fmt.Sprintf("```\n%s\n```", inputPreview) + } else { + formattedInput = fmt.Sprintf("`%s`", inputPreview) + } + e.send(p, replyCtx, fmt.Sprintf(e.i18n.T(MsgTool), toolCount, event.ToolName, formattedInput)) } case EventText: @@ -1428,7 +1436,7 @@ func (e *Engine) cmdSwitch(p Platform, msg *Message, args []string) { displayName = matched.Summary } e.reply(p, msg.ReplyCtx, - fmt.Sprintf("✅ Switched to: %s (%s, %d msgs)", displayName, shortID, matched.MessageCount)) + e.i18n.Tf(MsgSwitchSuccess, displayName, shortID, matched.MessageCount)) } // matchSession resolves a user query to an agent session. Priority: @@ -1655,6 +1663,13 @@ func (e *Engine) cmdStatus(p Platform, msg *Message) { } } + // Quiet mode + quietStr := e.i18n.T(MsgQuietOffShort) + if state, ok := e.interactiveStates[msg.SessionKey]; ok && state.quiet { + quietStr = e.i18n.T(MsgQuietOnShort) + } + modeStr += e.i18n.Tf(MsgStatusQuiet, quietStr) + // Session info s := e.sessions.GetOrCreateActive(msg.SessionKey) sessionDisplayName := e.sessions.GetSessionName(s.AgentSessionID) diff --git a/core/i18n.go b/core/i18n.go index 91cfbd9..47892d5 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -215,6 +215,9 @@ const ( MsgStatusMode MsgKey = "status_mode" MsgStatusSession MsgKey = "status_session" MsgStatusCron MsgKey = "status_cron" + MsgStatusQuiet MsgKey = "status_quiet" + MsgQuietOnShort MsgKey = "quiet_on_short" + MsgQuietOffShort MsgKey = "quiet_off_short" MsgModelDefault MsgKey = "model_default" MsgModelListTitle MsgKey = "model_list_title" @@ -257,8 +260,8 @@ const ( MsgDoctorTitle MsgKey = "doctor_title" MsgDoctorSummary MsgKey = "doctor_summary" - MsgRestarting MsgKey = "restarting" - MsgRestartSuccess MsgKey = "restart_success" + MsgRestarting MsgKey = "restarting" + MsgRestartSuccess MsgKey = "restart_success" MsgUpgradeChecking MsgKey = "upgrade_checking" MsgUpgradeUpToDate MsgKey = "upgrade_up_to_date" @@ -267,12 +270,12 @@ const ( MsgUpgradeSuccess MsgKey = "upgrade_success" MsgUpgradeDevBuild MsgKey = "upgrade_dev_build" - MsgAliasEmpty MsgKey = "alias_empty" + MsgAliasEmpty MsgKey = "alias_empty" MsgAliasListHeader MsgKey = "alias_list_header" - MsgAliasAdded MsgKey = "alias_added" - MsgAliasDeleted MsgKey = "alias_deleted" - MsgAliasNotFound MsgKey = "alias_not_found" - MsgAliasUsage MsgKey = "alias_usage" + MsgAliasAdded MsgKey = "alias_added" + MsgAliasDeleted MsgKey = "alias_deleted" + MsgAliasNotFound MsgKey = "alias_not_found" + MsgAliasUsage MsgKey = "alias_usage" MsgNewSessionCreated MsgKey = "new_session_created" MsgNewSessionCreatedName MsgKey = "new_session_created_name" @@ -282,6 +285,8 @@ const ( MsgDeleteActiveDenied MsgKey = "delete_active_denied" MsgDeleteNotSupported MsgKey = "delete_not_supported" + MsgSwitchSuccess MsgKey = "switch_success" + MsgBannedWordBlocked MsgKey = "banned_word_blocked" MsgCommandDisabled MsgKey = "command_disabled" MsgRateLimited MsgKey = "rate_limited" @@ -296,11 +301,11 @@ const ( MsgRelayNotFound MsgKey = "relay_not_found" MsgRelayNoTarget MsgKey = "relay_no_target" - MsgSearchUsage MsgKey = "search_usage" - MsgSearchError MsgKey = "search_error" - MsgSearchNoResult MsgKey = "search_no_result" - MsgSearchResult MsgKey = "search_result" - MsgSearchHint MsgKey = "search_hint" + MsgSearchUsage MsgKey = "search_usage" + MsgSearchError MsgKey = "search_error" + MsgSearchNoResult MsgKey = "search_no_result" + MsgSearchResult MsgKey = "search_result" + MsgSearchHint MsgKey = "search_hint" ) var messages = map[MsgKey]map[Language]string{ @@ -316,11 +321,11 @@ var messages = map[MsgKey]map[Language]string{ LangChinese: "💭 %s", }, MsgTool: { - LangEnglish: "🔧 Tool #%d: **%s**\n`%s`", - LangChinese: "🔧 工具 #%d: **%s**\n`%s`", - LangTraditionalChinese: "🔧 工具 #%d: **%s**\n`%s`", - LangJapanese: "🔧 ツール #%d: **%s**\n`%s`", - LangSpanish: "🔧 Herramienta #%d: **%s**\n`%s`", + LangEnglish: "🔧 **Tool #%d: %s**\n---\n%s", + LangChinese: "🔧 **工具 #%d: %s**\n---\n%s", + LangTraditionalChinese: "🔧 **工具 #%d: %s**\n---\n%s", + LangJapanese: "🔧 **ツール #%d: %s**\n---\n%s", + LangSpanish: "🔧 **Herramienta #%d: %s**\n---\n%s", }, MsgExecutionStopped: { LangEnglish: "⏹ Execution stopped.", @@ -1147,6 +1152,27 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "スケジュールタスク: %d (有効: %d)\n", LangSpanish: "Tareas programadas: %d (habilitadas: %d)\n", }, + MsgStatusQuiet: { + LangEnglish: "Quiet mode: %s\n", + LangChinese: "安静模式: %s\n", + LangTraditionalChinese: "安靜模式: %s\n", + LangJapanese: "出力抑制モード: %s\n", + LangSpanish: "Modo silencioso: %s\n", + }, + MsgQuietOnShort: { + LangEnglish: "ON", + LangChinese: "开启", + LangTraditionalChinese: "開啟", + LangJapanese: "ON", + LangSpanish: "Activado", + }, + MsgQuietOffShort: { + LangEnglish: "OFF", + LangChinese: "关闭", + LangTraditionalChinese: "關閉", + LangJapanese: "OFF", + LangSpanish: "Desactivado", + }, MsgModelDefault: { LangEnglish: "Current model: (not set, using agent default)\n", LangChinese: "当前模型: (未设置,使用 Agent 默认值)\n", @@ -1545,6 +1571,13 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "🗑️ セッション削除:%s", LangSpanish: "🗑️ Sesión eliminada: %s", }, + MsgSwitchSuccess: { + LangEnglish: "✅ Switched to: %s (%s, %d msgs)", + LangChinese: "✅ 已切换到:%s(%s,%d 条消息)", + LangTraditionalChinese: "✅ 已切換到:%s(%s,%d 則訊息)", + LangJapanese: "✅ 切り替え:%s(%s、%d件)", + LangSpanish: "✅ Cambiado a: %s (%s, %d mensajes)", + }, MsgDeleteActiveDenied: { LangEnglish: "❌ Cannot delete the currently active session. Switch to another session first.", LangChinese: "❌ 不能删除当前活跃会话,请先切换到其他会话。", diff --git a/core/streaming.go b/core/streaming.go index 6e121f0..75ef672 100644 --- a/core/streaming.go +++ b/core/streaming.go @@ -3,25 +3,28 @@ package core import ( "context" "log/slog" + "strings" "sync" "time" ) // StreamPreviewCfg controls the streaming preview behavior. type StreamPreviewCfg struct { - Enabled bool // global toggle - IntervalMs int // minimum ms between updates (default 1500) - MinDeltaChars int // minimum new chars before sending an update (default 30) - MaxChars int // max preview length (default 2000) + Enabled bool // global toggle + DisabledPlatforms []string // platforms where streaming preview is disabled (e.g. "feishu") + IntervalMs int // minimum ms between updates (default 1500) + MinDeltaChars int // minimum new chars before sending an update (default 30) + MaxChars int // max preview length (default 2000) } // DefaultStreamPreviewCfg returns sensible defaults. func DefaultStreamPreviewCfg() StreamPreviewCfg { return StreamPreviewCfg{ - Enabled: true, - IntervalMs: 1500, - MinDeltaChars: 30, - MaxChars: 2000, + Enabled: true, + DisabledPlatforms: nil, + IntervalMs: 1500, + MinDeltaChars: 30, + MaxChars: 2000, } } @@ -73,11 +76,18 @@ func newStreamPreview(cfg StreamPreviewCfg, p Platform, replyCtx any, ctx contex } } -// canPreview returns true if the platform supports message updating. +// canPreview returns true if the platform supports message updating and is not disabled. func (sp *streamPreview) canPreview() bool { if sp.degraded || !sp.cfg.Enabled { return false } + // Check if platform is in disabled list + platformName := sp.platform.Name() + for _, disabled := range sp.cfg.DisabledPlatforms { + if strings.EqualFold(disabled, platformName) { + return false + } + } _, ok := sp.platform.(MessageUpdater) return ok } diff --git a/go.mod b/go.mod index f91bb10..47b2e9f 100644 --- a/go.mod +++ b/go.mod @@ -10,13 +10,13 @@ require ( github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/line/line-bot-sdk-go/v8 v8.19.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 + github.com/robfig/cron/v3 v3.0.1 github.com/slack-go/slack v0.15.0 ) require ( github.com/gogo/protobuf v1.3.2 // indirect github.com/google/uuid v1.3.0 // indirect - github.com/robfig/cron/v3 v3.0.1 // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect ) diff --git a/platform/feishu/feishu.go b/platform/feishu/feishu.go index 05c8fe3..2204978 100644 --- a/platform/feishu/feishu.go +++ b/platform/feishu/feishu.go @@ -449,12 +449,16 @@ func buildReplyContent(content string) (msgType string, body string) { b, _ := json.Marshal(map[string]string{"text": content}) return larkim.MsgTypeText, string(b) } - // Dual rendering strategy (aligned with Claude-to-IM / Openclaw): - // - Code blocks / tables → interactive card (schema 2.0 markdown), smaller font but renders code properly - // - Other markdown → post with md tag, normal chat font size + // Three-tier rendering strategy: + // 1. Code blocks / tables → card (schema 2.0 markdown) + // 2. Many \n\n paragraphs (help, status, etc.) → post rich-text (preserves blank lines) + // 3. Other markdown → post md tag (best native rendering) if hasComplexMarkdown(content) { return larkim.MsgTypeInteractive, buildCardJSON(preprocessFeishuMarkdown(content)) } + if strings.Count(content, "\n\n") >= 4 { + return larkim.MsgTypePost, buildPostJSON(content) + } return larkim.MsgTypePost, buildPostMdJSON(content) } @@ -937,3 +941,4 @@ func (p *Platform) extractPostParts(messageID string, post *postLang) ([]string, } return textParts, images } +