diff --git a/cmd/cc-connect/main.go b/cmd/cc-connect/main.go index c121b1884..518d0772a 100644 --- a/cmd/cc-connect/main.go +++ b/cmd/cc-connect/main.go @@ -270,6 +270,15 @@ func main() { }) } + // Wire local reference normalization / rendering + engine.SetReferenceConfig(core.ReferenceRenderCfg{ + NormalizeAgents: proj.References.NormalizeAgents, + RenderPlatforms: proj.References.RenderPlatforms, + DisplayPath: proj.References.DisplayPath, + MarkerStyle: proj.References.MarkerStyle, + EnclosureStyle: proj.References.EnclosureStyle, + }) + // Wire streaming preview { spcfg := core.DefaultStreamPreviewCfg() diff --git a/config.example.toml b/config.example.toml index 88f8c3889..2591d8e9f 100644 --- a/config.example.toml +++ b/config.example.toml @@ -643,6 +643,28 @@ mode = "default" # "default" | "acceptEdits" (edit) | "plan" | "auto" | "bypassP # name = "bedrock" # env = { CLAUDE_CODE_USE_BEDROCK = "1", AWS_PROFILE = "bedrock" } +# Reference normalization and rendering / 本地引用标准化与展示优化 +# Disabled by default. When enabled, cc-connect can normalize Codex / Claude Code +# local file references (paths, line refs, markdown file links) and re-render them +# into a more readable platform-friendly form before sending to supported IMs. +# 默认关闭。启用后,cc-connect 可对 Codex / Claude Code 的本地文件引用 +# (路径、行号引用、Markdown 文件链接)做标准化,并在发送到支持的平台前 +# 重新渲染为更易读的展示形式。 +# +# Supported today / 当前已支持: +# normalize_agents: codex | claudecode | all +# render_platforms: feishu | weixin | all +# Note: render depends on normalize. If normalize_agents is empty, render_platforms +# alone has no effect. +# 注意:render 依赖 normalize。若 normalize_agents 为空,仅设置 render_platforms 不生效。 +# +# [projects.references] +# normalize_agents = ["codex", "claudecode"] +# render_platforms = ["feishu", "weixin"] +# display_path = "dirname_basename" # absolute | relative | basename | dirname_basename | smart +# marker_style = "emoji" # none | ascii | emoji +# enclosure_style = "code" # none | bracket | angle | fullwidth | code + # Feishu / Lark / 飞书 # 1. Create an app at https://open.feishu.cn / 在 https://open.feishu.cn 创建应用 # 2. Enable Bot capability / 开启机器人能力 diff --git a/config/config.go b/config/config.go index 2a66bdd67..22e9e0df3 100644 --- a/config/config.go +++ b/config/config.go @@ -196,6 +196,15 @@ type AutoCompressConfig struct { MinGapMins *int `toml:"min_gap_mins,omitempty"` // minimum minutes between auto-compress runs (default 30) } +// ReferenceConfig controls local file reference normalization and rendering. +type ReferenceConfig struct { + NormalizeAgents []string `toml:"normalize_agents,omitempty"` + RenderPlatforms []string `toml:"render_platforms,omitempty"` + DisplayPath string `toml:"display_path,omitempty"` + MarkerStyle string `toml:"marker_style,omitempty"` + EnclosureStyle string `toml:"enclosure_style,omitempty"` +} + // ProjectConfig binds one agent (with a specific work_dir) to one or more platforms. type ProjectConfig struct { Name string `toml:"name"` @@ -210,14 +219,15 @@ type ProjectConfig struct { // 0 or nil disables the behavior. ResetOnIdleMins *int `toml:"reset_on_idle_mins,omitempty"` // ShowContextIndicator: nil/true = append [ctx: ~N%] to assistant replies; false = hide. - ShowContextIndicator *bool `toml:"show_context_indicator,omitempty"` - InjectSender *bool `toml:"inject_sender,omitempty"` // prepend sender identity (platform + user ID) to each message sent to the agent - DisabledCommands []string `toml:"disabled_commands,omitempty"` // commands to disable for this project (e.g. ["restart", "upgrade"]) - AdminFrom string `toml:"admin_from,omitempty"` // comma-separated user IDs allowed to run privileged commands; "*" = all allowed users - Users *UsersConfig `toml:"users,omitempty"` // per-user role config; nil = legacy behavior + ShowContextIndicator *bool `toml:"show_context_indicator,omitempty"` + InjectSender *bool `toml:"inject_sender,omitempty"` // prepend sender identity (platform + user ID) to each message sent to the agent + DisabledCommands []string `toml:"disabled_commands,omitempty"` // commands to disable for this project (e.g. ["restart", "upgrade"]) + AdminFrom string `toml:"admin_from,omitempty"` // comma-separated user IDs allowed to run privileged commands; "*" = all allowed users + Users *UsersConfig `toml:"users,omitempty"` // per-user role config; nil = legacy behavior // Quiet is legacy per-project override; see Config.Quiet. When true and global [display] // omits thinking_messages / tool_messages, those default to off for this project. - Quiet *bool `toml:"quiet,omitempty"` + Quiet *bool `toml:"quiet,omitempty"` + References ReferenceConfig `toml:"references,omitempty"` } type AgentConfig struct { @@ -380,6 +390,9 @@ func (c *Config) validate() error { if proj.ResetOnIdleMins != nil && *proj.ResetOnIdleMins < 0 { return fmt.Errorf("config: %s.reset_on_idle_mins must be >= 0", prefix) } + if err := validateReferenceConfig(prefix, proj.References); err != nil { + return err + } if err := validateUsersConfig(prefix, proj.Users); err != nil { return err } @@ -387,6 +400,68 @@ func (c *Config) validate() error { return nil } +var supportedReferenceAgents = map[string]struct{}{ + "all": {}, + "codex": {}, + "claudecode": {}, +} + +var supportedReferencePlatforms = map[string]struct{}{ + "all": {}, + "feishu": {}, + "weixin": {}, +} + +var supportedReferenceDisplayPaths = map[string]struct{}{ + "": {}, + "absolute": {}, + "relative": {}, + "basename": {}, + "dirname_basename": {}, + "smart": {}, +} + +var supportedReferenceMarkerStyles = map[string]struct{}{ + "": {}, + "none": {}, + "ascii": {}, + "emoji": {}, +} + +var supportedReferenceEnclosureStyles = map[string]struct{}{ + "": {}, + "none": {}, + "bracket": {}, + "angle": {}, + "fullwidth": {}, + "code": {}, +} + +func validateReferenceConfig(prefix string, rc ReferenceConfig) error { + for _, v := range rc.NormalizeAgents { + key := strings.ToLower(strings.TrimSpace(v)) + if _, ok := supportedReferenceAgents[key]; !ok { + return fmt.Errorf("config: %s.references.normalize_agents has unsupported value %q", prefix, v) + } + } + for _, v := range rc.RenderPlatforms { + key := strings.ToLower(strings.TrimSpace(v)) + if _, ok := supportedReferencePlatforms[key]; !ok { + return fmt.Errorf("config: %s.references.render_platforms has unsupported value %q", prefix, v) + } + } + if _, ok := supportedReferenceDisplayPaths[strings.ToLower(strings.TrimSpace(rc.DisplayPath))]; !ok { + return fmt.Errorf("config: %s.references.display_path has unsupported value %q", prefix, rc.DisplayPath) + } + if _, ok := supportedReferenceMarkerStyles[strings.ToLower(strings.TrimSpace(rc.MarkerStyle))]; !ok { + return fmt.Errorf("config: %s.references.marker_style has unsupported value %q", prefix, rc.MarkerStyle) + } + if _, ok := supportedReferenceEnclosureStyles[strings.ToLower(strings.TrimSpace(rc.EnclosureStyle))]; !ok { + return fmt.Errorf("config: %s.references.enclosure_style has unsupported value %q", prefix, rc.EnclosureStyle) + } + return nil +} + // validateUsersConfig checks the [projects.users] section for consistency. func validateUsersConfig(prefix string, u *UsersConfig) error { if u == nil { diff --git a/config/config_test.go b/config/config_test.go index 57a3ad9f2..47b2086e8 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -102,6 +102,76 @@ func TestConfigValidate(t *testing.T) { Projects: []ProjectConfig{validProject("demo")}, }, }, + { + name: "accepts valid references config", + cfg: Config{ + Projects: []ProjectConfig{ + func() ProjectConfig { + p := validProject("demo") + p.References = ReferenceConfig{ + NormalizeAgents: []string{"codex", "claudecode"}, + RenderPlatforms: []string{"feishu", "weixin"}, + DisplayPath: "dirname_basename", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + return p + }(), + }, + }, + }, + { + name: "rejects unsupported reference agent", + cfg: Config{ + Projects: []ProjectConfig{ + func() ProjectConfig { + p := validProject("demo") + p.References.NormalizeAgents = []string{"gemini"} + return p + }(), + }, + }, + wantErr: `projects[0].references.normalize_agents has unsupported value "gemini"`, + }, + { + name: "rejects unsupported reference platform", + cfg: Config{ + Projects: []ProjectConfig{ + func() ProjectConfig { + p := validProject("demo") + p.References.RenderPlatforms = []string{"telegram"} + return p + }(), + }, + }, + wantErr: `projects[0].references.render_platforms has unsupported value "telegram"`, + }, + { + name: "rejects unsupported reference display path", + cfg: Config{ + Projects: []ProjectConfig{ + func() ProjectConfig { + p := validProject("demo") + p.References.DisplayPath = "full" + return p + }(), + }, + }, + wantErr: `projects[0].references.display_path has unsupported value "full"`, + }, + { + name: "accepts all shorthand in references scopes", + cfg: Config{ + Projects: []ProjectConfig{ + func() ProjectConfig { + p := validProject("demo") + p.References.NormalizeAgents = []string{"all"} + p.References.RenderPlatforms = []string{"all"} + return p + }(), + }, + }, + }, } for _, tt := range tests { diff --git a/core/engine.go b/core/engine.go index b92db460e..52a9ef719 100644 --- a/core/engine.go +++ b/core/engine.go @@ -185,6 +185,7 @@ type Engine struct { rateLimiter *RateLimiter outgoingRL *OutgoingRateLimiter streamPreview StreamPreviewCfg + references ReferenceRenderCfg relayManager *RelayManager eventIdleTimeout time.Duration dirHistory *DirHistory @@ -339,6 +340,7 @@ func NewEngine(name string, ag Agent, platforms []Platform, sessionStorePath str platformReady: make(map[Platform]bool), startedAt: time.Now(), streamPreview: DefaultStreamPreviewCfg(), + references: DefaultReferenceRenderCfg(), eventIdleTimeout: defaultEventIdleTimeout, showContextIndicator: true, } @@ -437,6 +439,11 @@ func (e *Engine) SetDisplayConfig(cfg DisplayCfg) { e.display = cfg } +// SetReferenceConfig configures local reference normalization/rendering. +func (e *Engine) SetReferenceConfig(cfg ReferenceRenderCfg) { + e.references = normalizeReferenceRenderCfg(cfg) +} + // estimateTokens provides a rough token estimate for a set of history entries. func estimateTokens(entries []HistoryEntry) int { return estimateTokensWithPendingAssistant(entries, "") @@ -662,7 +669,7 @@ func (e *Engine) SetAdminFrom(adminFrom string) { shellDisabled := e.disabledCmds["shell"] e.userRolesMu.Unlock() if af == "" && !shellDisabled { - slog.Warn("admin_from is not set — privileged commands (/shell, /dir, /restart, /upgrade) are blocked. "+ + slog.Warn("admin_from is not set — privileged commands (/shell, /show, /dir, /restart, /upgrade) are blocked. "+ "Set admin_from in config to enable them, or use disabled_commands to hide them.", "project", e.name) } @@ -671,6 +678,7 @@ func (e *Engine) SetAdminFrom(adminFrom string) { // privilegedCommands are commands that require admin_from authorization. var privilegedCommands = map[string]bool{ "shell": true, + "show": true, "dir": true, "restart": true, "upgrade": true, @@ -2251,8 +2259,18 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess }() state.mu.Lock() - sp := newStreamPreview(e.streamPreview, state.platform, state.replyCtx, e.ctx) - cp := newCompactProgressWriter(e.ctx, state.platform, state.replyCtx, e.agent.Name(), e.i18n.CurrentLang()) + workspaceDir := state.workspaceDir + workspaceRenderer := func(content string) string { + return e.renderOutgoingContentForWorkspace(state.platform, content, workspaceDir) + } + sendWorkspace := func(p Platform, replyCtx any, content string) { + e.sendForWorkspace(p, replyCtx, content, workspaceDir) + } + sendWorkspaceWithError := func(p Platform, replyCtx any, content string) error { + return e.sendWithErrorForWorkspace(p, replyCtx, content, workspaceDir) + } + sp := newStreamPreview(e.streamPreview, state.platform, state.replyCtx, e.ctx, workspaceRenderer) + cp := newCompactProgressWriter(e.ctx, state.platform, state.replyCtx, e.agent.Name(), e.i18n.CurrentLang(), workspaceRenderer) state.mu.Unlock() // Idle timeout: 0 = disabled @@ -2350,7 +2368,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess segment := strings.Join(textParts[segmentStart:], "") if segment != "" { for _, chunk := range splitMessage(segment, maxPlatformMessageLen) { - e.send(p, replyCtx, chunk) + sendWorkspace(p, replyCtx, chunk) } } } @@ -2363,7 +2381,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess preview := truncateIf(event.Content, e.display.ThinkingMaxLen) thinkingMsg := fmt.Sprintf(e.i18n.T(MsgThinking), preview) if !cp.AppendEvent(ProgressEntryThinking, preview, "", thinkingMsg) { - e.send(p, replyCtx, thinkingMsg) + sendWorkspace(p, replyCtx, thinkingMsg) } } @@ -2377,7 +2395,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess segment := strings.Join(textParts[segmentStart:], "") if segment != "" { for _, chunk := range splitMessage(segment, maxPlatformMessageLen) { - e.send(p, replyCtx, chunk) + sendWorkspace(p, replyCtx, chunk) } } } @@ -2408,7 +2426,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess toolMsg := fmt.Sprintf(e.i18n.T(MsgTool), toolCount, event.ToolName, formattedInput) if !cp.AppendEvent(ProgressEntryToolUse, toolInput, event.ToolName, toolMsg) { for _, chunk := range SplitMessageCodeFenceAware(toolMsg, maxPlatformMessageLen) { - e.send(p, replyCtx, chunk) + sendWorkspace(p, replyCtx, chunk) } } } @@ -2434,7 +2452,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess } if !cp.AppendStructured(entry, resultMsg) { if !SuppressStandaloneToolResultEvent(p) { - e.send(p, replyCtx, resultMsg) + e.sendRaw(p, replyCtx, resultMsg) } } } @@ -2480,7 +2498,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess segment := strings.Join(textParts[segmentStart:], "") if segment != "" { for _, chunk := range splitMessage(segment, maxPlatformMessageLen) { - e.send(p, replyCtx, chunk) + sendWorkspace(p, replyCtx, chunk) } } } @@ -2611,7 +2629,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess unsent := strings.Join(textParts[segmentStart:], "") if unsent != "" { for _, chunk := range splitMessage(unsent, maxPlatformMessageLen) { - if err := e.sendWithError(p, replyCtx, chunk); err != nil { + if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil { return } } @@ -2625,7 +2643,7 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess } else { slog.Debug("EventResult: sending via p.Send (preview inactive or failed)", "response_len", len(fullResponse), "chunks", len(splitMessage(fullResponse, maxPlatformMessageLen))) for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) { - if err := e.sendWithError(p, replyCtx, chunk); err != nil { + if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil { return } } @@ -2729,8 +2747,11 @@ func (e *Engine) processInteractiveEvents(state *interactiveState, session *Sess turnStart = time.Now() firstEventLogged = false waitStart = time.Now() - sp = newStreamPreview(e.streamPreview, queued.platform, queued.replyCtx, e.ctx) - cp = newCompactProgressWriter(e.ctx, queued.platform, queued.replyCtx, e.agent.Name(), e.i18n.CurrentLang()) + queuedRenderer := func(content string) string { + return e.renderOutgoingContentForWorkspace(queued.platform, content, workspaceDir) + } + sp = newStreamPreview(e.streamPreview, queued.platform, queued.replyCtx, e.ctx, queuedRenderer) + cp = newCompactProgressWriter(e.ctx, queued.platform, queued.replyCtx, e.agent.Name(), e.i18n.CurrentLang(), queuedRenderer) session.AddHistory("user", queued.content) @@ -2796,7 +2817,7 @@ channelClosed: unsent := strings.Join(textParts[segmentStart:], "") if unsent != "" { for _, chunk := range splitMessage(unsent, maxPlatformMessageLen) { - if err := e.sendWithError(p, replyCtx, chunk); err != nil { + if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil { return } } @@ -2806,7 +2827,7 @@ channelClosed: slog.Debug("stream preview: finalized in-place (process exited)") } else { for _, chunk := range splitMessage(fullResponse, maxPlatformMessageLen) { - if err := e.sendWithError(p, replyCtx, chunk); err != nil { + if err := sendWorkspaceWithError(p, replyCtx, chunk); err != nil { return } } @@ -2918,6 +2939,7 @@ var builtinCommands = []struct { {[]string{"bind"}, "bind"}, {[]string{"search", "find"}, "search"}, {[]string{"shell", "sh", "exec", "run"}, "shell"}, + {[]string{"show"}, "show"}, {[]string{"dir", "cd", "chdir", "workdir"}, "dir"}, {[]string{"tts"}, "tts"}, {[]string{"workspace", "ws"}, "workspace"}, @@ -3107,6 +3129,8 @@ func (e *Engine) handleCommand(p Platform, msg *Message, raw string) bool { e.cmdShell(p, msg, raw) case "diff": e.cmdDiff(p, msg, raw) + case "show": + e.cmdShow(p, msg, args) case "dir": e.cmdDir(p, msg, args) case "tts": @@ -3598,6 +3622,67 @@ func (e *Engine) matchSession(sessions []AgentSessionInfo, manager *SessionManag return nil } +func (e *Engine) commandWorkDir(agent Agent, msg *Message) string { + if switcher, ok := agent.(WorkDirSwitcher); ok { + if wd := strings.TrimSpace(switcher.GetWorkDir()); wd != "" { + return normalizeWorkspacePath(wd) + } + } + if e.multiWorkspace { + channelKey := effectiveWorkspaceChannelKey(msg) + if b, _, usable := e.lookupEffectiveWorkspaceBinding(channelKey); usable { + return normalizeWorkspacePath(b.Workspace) + } + } + if wd, ok := agent.(interface{ GetWorkDir() string }); ok { + if dir := strings.TrimSpace(wd.GetWorkDir()); dir != "" { + return normalizeWorkspacePath(dir) + } + } + if wd, ok := e.agent.(interface{ GetWorkDir() string }); ok { + if dir := strings.TrimSpace(wd.GetWorkDir()); dir != "" { + return normalizeWorkspacePath(dir) + } + } + if cwd, err := os.Getwd(); err == nil { + return normalizeWorkspacePath(cwd) + } + return "" +} + +func (e *Engine) cmdShow(p Platform, msg *Message, args []string) { + rawRef := strings.TrimSpace(strings.Join(args, " ")) + if rawRef == "" { + e.reply(p, msg.ReplyCtx, e.i18n.T(MsgShowUsage)) + return + } + + agent, _, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return + } + workDir := e.commandWorkDir(agent, msg) + req, err := buildReferenceViewRequest(rawRef, workDir) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgShowParseError, rawRef)) + return + } + content, err := renderReferenceView(req) + if err != nil { + switch { + case strings.Contains(err.Error(), "path does not exist"): + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgShowNotFound, rawRef)) + case strings.Contains(err.Error(), "directory reference cannot carry a location"): + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgShowDirWithLocation, rawRef)) + default: + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgShowReadFailed, err)) + } + return + } + e.reply(p, msg.ReplyCtx, content) +} + func (e *Engine) cmdShell(p Platform, msg *Message, raw string) { // Strip the command prefix ("/shell ", "/sh ", "/exec ", "/run ") shellCmd := raw @@ -3614,19 +3699,12 @@ func (e *Engine) cmdShell(p Platform, msg *Message, raw string) { return } - // In multi-workspace mode, resolve workspace directory for this channel - var workDir string - if e.multiWorkspace { - channelKey := effectiveWorkspaceChannelKey(msg) - if b, _, usable := e.lookupEffectiveWorkspaceBinding(channelKey); usable { - workDir = normalizeWorkspacePath(b.Workspace) - } - } - if workDir == "" { - if wd, ok := e.agent.(interface{ GetWorkDir() string }); ok { - workDir = normalizeWorkspacePath(wd.GetWorkDir()) - } + agent, _, _, err := e.commandContext(p, msg) + if err != nil { + e.reply(p, msg.ReplyCtx, e.i18n.Tf(MsgWsResolutionError, err)) + return } + workDir := e.commandWorkDir(agent, msg) if workDir == "" { workDir, _ = os.Getwd() } @@ -4818,6 +4896,7 @@ func helpCardGroups() []helpCardGroup { titleKey: MsgHelpToolsSection, items: []helpCardItem{ {command: "/shell", action: "cmd:/shell"}, + {command: "/show", action: "cmd:/show"}, {command: "/cron", action: "nav:/cron"}, {command: "/heartbeat", action: "nav:/heartbeat"}, {command: "/commands", action: "nav:/commands"}, @@ -6249,6 +6328,73 @@ func (e *Engine) waitOutgoing(p Platform) error { return e.outgoingRL.Wait(e.ctx, p.Name()) } +func (e *Engine) renderOutgoingContentForWorkspace(p Platform, content, workspaceDir string) string { + if strings.TrimSpace(content) == "" { + return content + } + return TransformLocalReferences(content, e.references, e.agent.Name(), p.Name(), workspaceDir) +} + +func (e *Engine) sendWithErrorForWorkspace(p Platform, replyCtx any, content, workspaceDir string) error { + if err := e.waitOutgoing(p); err != nil { + slog.Warn("outgoing rate limit: context cancelled", "platform", p.Name(), "error", err) + return err + } + content = e.renderOutgoingContentForWorkspace(p, content, workspaceDir) + return e.sendAlreadyRenderedWithError(p, replyCtx, content) +} + +func (e *Engine) sendForWorkspace(p Platform, replyCtx any, content, workspaceDir string) { + _ = e.sendWithErrorForWorkspace(p, replyCtx, content, workspaceDir) +} + +func (e *Engine) renderCardForPlatform(p Platform, card *Card) *Card { + return e.renderCardForPlatformWorkspace(p, card, "") +} + +func (e *Engine) renderCardForPlatformWorkspace(p Platform, card *Card, workspaceDir string) *Card { + if card == nil { + return nil + } + out := &Card{} + if card.Header != nil { + h := *card.Header + out.Header = &h + } + out.Elements = make([]CardElement, 0, len(card.Elements)) + for _, elem := range card.Elements { + switch v := elem.(type) { + case CardMarkdown: + content := v.Content + if workspaceDir != "" { + content = e.renderOutgoingContentForWorkspace(p, v.Content, workspaceDir) + } + out.Elements = append(out.Elements, CardMarkdown{Content: content}) + case CardNote: + text := v.Text + if workspaceDir != "" { + text = e.renderOutgoingContentForWorkspace(p, v.Text, workspaceDir) + } + out.Elements = append(out.Elements, CardNote{Text: text, Tag: v.Tag}) + case CardListItem: + text := v.Text + if workspaceDir != "" { + text = e.renderOutgoingContentForWorkspace(p, v.Text, workspaceDir) + } + out.Elements = append(out.Elements, CardListItem{ + Text: text, + BtnText: v.BtnText, + BtnType: v.BtnType, + BtnValue: v.BtnValue, + Extra: v.Extra, + }) + default: + out.Elements = append(out.Elements, elem) + } + } + return out +} + // sendWithError applies outgoing rate limiting and p.Send. It logs wait // cancellation and platform failures, and returns a non-nil error on either. func (e *Engine) sendWithError(p Platform, replyCtx any, content string) error { @@ -6256,6 +6402,10 @@ func (e *Engine) sendWithError(p Platform, replyCtx any, content string) error { slog.Warn("outgoing rate limit: context cancelled", "platform", p.Name(), "error", err) return err } + return e.sendAlreadyRenderedWithError(p, replyCtx, content) +} + +func (e *Engine) sendAlreadyRenderedWithError(p Platform, replyCtx any, content string) error { start := time.Now() if err := p.Send(e.ctx, replyCtx, content); err != nil { slog.Error("platform send failed", "platform", p.Name(), "error", err, "content_len", len(content)) @@ -6272,6 +6422,17 @@ func (e *Engine) send(p Platform, replyCtx any, content string) { _ = e.sendWithError(p, replyCtx, content) } +// sendRaw sends content without local-reference rendering. This is used for raw +// tool outputs, where preserving the original text is preferable to applying the +// agent-facing reference display transform. +func (e *Engine) sendRaw(p Platform, replyCtx any, content string) { + if err := e.waitOutgoing(p); err != nil { + slog.Warn("outgoing rate limit: context cancelled", "platform", p.Name(), "error", err) + return + } + _ = e.sendAlreadyRenderedWithError(p, replyCtx, content) +} + // drainEvents discards any buffered events from the channel. // Called before a new turn to prevent stale events from a previous turn's // agent process from being mistaken for the new turn's response. @@ -6348,12 +6509,13 @@ func (e *Engine) replyWithCard(p Platform, replyCtx any, card *Card) { return } if cs, ok := p.(CardSender); ok { - if err := cs.ReplyCard(e.ctx, replyCtx, card); err != nil { + rendered := e.renderCardForPlatform(p, card) + if err := cs.ReplyCard(e.ctx, replyCtx, rendered); err != nil { slog.Error("card reply failed", "platform", p.Name(), "error", err) } return } - e.reply(p, replyCtx, card.RenderText()) + e.reply(p, replyCtx, e.renderCardForPlatform(p, card).RenderText()) } // sendWithCard sends a card as a new message (not a reply). @@ -6367,12 +6529,13 @@ func (e *Engine) sendWithCard(p Platform, replyCtx any, card *Card) { return } if cs, ok := p.(CardSender); ok { - if err := cs.SendCard(e.ctx, replyCtx, card); err != nil { + rendered := e.renderCardForPlatform(p, card) + if err := cs.SendCard(e.ctx, replyCtx, rendered); err != nil { slog.Error("card send failed", "platform", p.Name(), "error", err) } return } - e.send(p, replyCtx, card.RenderText()) + e.send(p, replyCtx, e.renderCardForPlatform(p, card).RenderText()) } // ────────────────────────────────────────────────────────────── diff --git a/core/engine_test.go b/core/engine_test.go index 07c03e17e..1f9e81f94 100644 --- a/core/engine_test.go +++ b/core/engine_test.go @@ -1060,6 +1060,87 @@ func TestProcessInteractiveEvents_CardProgressUsesCardTemplate(t *testing.T) { } } +func TestProcessInteractiveEvents_FinalReplyUsesWorkspaceForReferenceRendering(t *testing.T) { + p := &stubPlatformEngine{n: "feishu"} + a := &namedStubModelModeAgent{name: "codex"} + e := NewEngine("test", a, []Platform{p}, "", LangEnglish) + e.SetReferenceConfig(ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + }) + + sessionKey := "feishu:user-relative" + session := e.sessions.GetOrCreateActive(sessionKey) + agentSession := newControllableSession("s-relative") + state := &interactiveState{ + agentSession: agentSession, + platform: p, + replyCtx: "ctx-relative", + workspaceDir: "/root/code", + } + e.interactiveStates[sessionKey] = state + + agentSession.events <- Event{ + Type: EventResult, + Content: "/root/code/demo-repo/src/services/user_profile_service.ts:42", + Done: true, + } + + e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-relative", time.Now(), nil, nil, state.replyCtx) + + sent := p.getSent() + if len(sent) != 1 { + t.Fatalf("sent = %#v, want one final reply", sent) + } + if got := sent[0]; got != "📄 `demo-repo/src/services/user_profile_service.ts:42`" { + t.Fatalf("final reply = %q, want workspace-relative rendered reference", got) + } +} + +func TestProcessInteractiveEvents_FinalReplyRemainsRawWhenReferencesDisabled(t *testing.T) { + p := &stubPlatformEngine{n: "feishu"} + a := &namedStubModelModeAgent{name: "codex"} + e := NewEngine("test", a, []Platform{p}, "", LangEnglish) + e.SetReferenceConfig(ReferenceRenderCfg{ + NormalizeAgents: []string{}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + }) + + sessionKey := "feishu:user-relative-raw" + session := e.sessions.GetOrCreateActive(sessionKey) + agentSession := newControllableSession("s-relative-raw") + state := &interactiveState{ + agentSession: agentSession, + platform: p, + replyCtx: "ctx-relative-raw", + workspaceDir: "/root/code/demo", + } + e.interactiveStates[sessionKey] = state + + raw := "Check [/root/code/demo/ui/recovery_contact_form.tsx](/root/code/demo/ui/recovery_contact_form.tsx) and /root/code/demo/ui/recovery_contact_form.tsx:11" + agentSession.events <- Event{ + Type: EventResult, + Content: raw, + Done: true, + } + + e.processInteractiveEvents(state, session, e.sessions, sessionKey, "m-relative-raw", time.Now(), nil, nil, state.replyCtx) + + sent := p.getSent() + if len(sent) != 1 { + t.Fatalf("sent = %#v, want one final reply", sent) + } + if got := sent[0]; got != raw { + t.Fatalf("final reply = %q, want raw unchanged content %q", got, raw) + } +} + func TestProcessInteractiveEvents_CardProgressUsesStructuredPayloadWhenSupported(t *testing.T) { p := &stubCompactProgressPlatform{ stubPlatformEngine: stubPlatformEngine{n: "feishu"}, @@ -2179,6 +2260,61 @@ func TestReplyWithCard_UsesCardSenderWhenSupported(t *testing.T) { } } +func TestReply_DoesNotTransformLocalReferencesWhenEnabled(t *testing.T) { + p := &stubPlatformEngine{n: "feishu"} + a := &namedStubModelModeAgent{name: "codex"} + e := NewEngine("test", a, []Platform{p}, "", LangEnglish) + e.SetBaseWorkDir("/root/code/demo") + e.SetReferenceConfig(ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + }) + + e.reply(p, "ctx", "See /root/code/demo/src/app.ts:42") + + if len(p.sent) != 1 { + t.Fatalf("sent messages = %d, want 1", len(p.sent)) + } + if got := p.sent[0]; got != "See /root/code/demo/src/app.ts:42" { + t.Fatalf("reply content = %q, want raw path", got) + } +} + +func TestReplyWithCard_DoesNotTransformMarkdownOrFallback(t *testing.T) { + p := &stubCardPlatform{stubPlatformEngine: stubPlatformEngine{n: "feishu"}} + a := &namedStubModelModeAgent{name: "codex"} + e := NewEngine("test", a, []Platform{p}, "", LangEnglish) + e.SetBaseWorkDir("/root/code/demo") + e.SetReferenceConfig(ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "basename", + MarkerStyle: "ascii", + EnclosureStyle: "code", + }) + card := NewCard().Markdown("Inspect /root/code/demo/src/app.ts:42").Build() + + e.replyWithCard(p, "ctx", card) + + if len(p.repliedCards) != 1 { + t.Fatalf("replied cards = %d, want 1", len(p.repliedCards)) + } + rendered := p.repliedCards[0] + md, ok := rendered.Elements[0].(CardMarkdown) + if !ok { + t.Fatalf("first card element = %T, want CardMarkdown", rendered.Elements[0]) + } + if md.Content != "Inspect /root/code/demo/src/app.ts:42" { + t.Fatalf("card markdown = %q, want raw reference", md.Content) + } + if got := rendered.RenderText(); !strings.Contains(got, "/root/code/demo/src/app.ts:42") { + t.Fatalf("fallback RenderText() = %q, want raw reference", got) + } +} + func TestCmdHelp_UsesLegacyTextOnPlatformWithoutCardSupport(t *testing.T) { p := &stubPlatformEngine{n: "plain"} e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangChinese) @@ -7055,6 +7191,136 @@ func TestCmdDiff_FileSenderPath(t *testing.T) { } } +func TestCmdShow_EmptyReference_ShowsUsage(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + e.SetAdminFrom("admin") + + msg := &Message{ + SessionKey: "test:ch:admin", + Content: "/show", + ReplyCtx: "ctx", + UserID: "admin", + Platform: "test", + } + e.cmdShow(p, msg, nil) + + sent := p.getSent() + if len(sent) != 1 || !strings.Contains(sent[0], "/show") { + t.Fatalf("sent = %v, want show usage", sent) + } +} + +func TestCmdShow_MultiWorkspaceUsesBoundWorkDirForRelativeReference(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + agentName := "test-show-workspace" + RegisterAgent(agentName, func(opts map[string]any) (Agent, error) { + return &namedStubModelModeAgent{name: agentName}, nil + }) + e := NewEngine("test", &namedStubModelModeAgent{name: agentName}, []Platform{p}, "", LangEnglish) + e.SetAdminFrom("admin") + + baseDir := t.TempDir() + bindStore := filepath.Join(t.TempDir(), "bindings.json") + e.SetMultiWorkspace(baseDir, bindStore) + + wsDir := filepath.Join(baseDir, "demo-repo") + if err := os.MkdirAll(filepath.Join(wsDir, "svc"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(wsDir, "svc", "handler.go"), []byte("package svc\n"), 0o644); err != nil { + t.Fatal(err) + } + e.workspaceBindings.Bind(sharedWorkspaceBindingsKey, "ch1", "demo", normalizeWorkspacePath(wsDir)) + + msg := &Message{ + SessionKey: "test:ch1:admin", + Content: "/show svc/handler.go", + ReplyCtx: "ctx", + UserID: "admin", + Platform: "test", + } + e.cmdShow(p, msg, []string{"svc/handler.go"}) + + deadline := time.Now().Add(500 * time.Millisecond) + for { + sent := p.getSent() + if len(sent) > 0 { + if !strings.Contains(sent[0], "📄 svc/handler.go") { + t.Fatalf("output = %q, want relative title", sent[0]) + } + if !strings.Contains(sent[0], "package svc") { + t.Fatalf("output = %q, want file content", sent[0]) + } + return + } + if time.Now().After(deadline) { + t.Fatal("timed out waiting for /show response") + } + time.Sleep(10 * time.Millisecond) + } +} + +func TestHandleCommand_ShowRequiresAdmin(t *testing.T) { + p := &stubPlatformEngine{n: "test"} + e := NewEngine("test", &stubAgent{}, []Platform{p}, "", LangEnglish) + e.SetAdminFrom("admin") + + msg := &Message{ + SessionKey: "test:ch:user1", + Content: "/show foo.txt", + ReplyCtx: "ctx", + UserID: "user1", + Platform: "test", + } + e.handleCommand(p, msg, msg.Content) + + sent := p.getSent() + if len(sent) != 1 || !strings.Contains(strings.ToLower(sent[0]), "admin") { + t.Fatalf("sent = %v, want admin required message", sent) + } +} + +func TestCmdShow_OutputRemainsRawWhenReferencesEnabled(t *testing.T) { + p := &stubPlatformEngine{n: "feishu"} + agent := &stubWorkDirAgent{workDir: t.TempDir()} + e := NewEngine("test", agent, []Platform{p}, "", LangEnglish) + e.SetAdminFrom("admin") + e.references = normalizeReferenceRenderCfg(ReferenceRenderCfg{ + NormalizeAgents: []string{"all"}, + RenderPlatforms: []string{"all"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + }) + + file := filepath.Join(agent.workDir, "svc", "handler.go") + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + rawLine := "/root/code/demo-repo/ui/recovery_contact_form.tsx:11" + if err := os.WriteFile(file, []byte(rawLine+"\n"), 0o644); err != nil { + t.Fatal(err) + } + + msg := &Message{ + SessionKey: "test:ch:admin", + Content: "/show svc/handler.go", + ReplyCtx: "ctx", + UserID: "admin", + Platform: "feishu", + } + e.cmdShow(p, msg, []string{"svc/handler.go"}) + + sent := p.getSent() + if len(sent) != 1 { + t.Fatalf("sent = %v, want one response", sent) + } + if !strings.Contains(sent[0], rawLine) { + t.Fatalf("output = %q, want raw code content preserved", sent[0]) + } +} + // --- 4. /workspace subcommands --- func TestWorkspace_NotEnabled_RepliesDisabled(t *testing.T) { diff --git a/core/i18n.go b/core/i18n.go index 22e62638e..019a82849 100644 --- a/core/i18n.go +++ b/core/i18n.go @@ -511,6 +511,12 @@ const ( MsgDirCardEmptyHistory MsgKey = "dir_card_empty_history" MsgDirCardReset MsgKey = "dir_card_reset" MsgDirCardPrev MsgKey = "dir_card_prev" + MsgShow MsgKey = "show" + MsgShowUsage MsgKey = "show_usage" + MsgShowParseError MsgKey = "show_parse_error" + MsgShowNotFound MsgKey = "show_not_found" + MsgShowDirWithLocation MsgKey = "show_dir_with_location" + MsgShowReadFailed MsgKey = "show_read_failed" // Multi-workspace messages MsgWsNotEnabled MsgKey = "ws_not_enabled" @@ -843,6 +849,7 @@ var messages = map[MsgKey]map[Language]string{ "/compress\n Compress conversation context\n\n" + "/tts [always|voice_only]\n View/switch text-to-speech mode\n\n" + "/shell \n Run a shell command and return the output\n\n" + + "/show \n View a file, directory, or code snippet by reference\n\n" + "/dir [path|reset]\n Show, switch, or reset agent working directory\n\n" + "/stop\n Stop current execution\n\n" + "/cron [add|list|del|enable|disable]\n Manage scheduled tasks\n\n" + @@ -885,6 +892,7 @@ var messages = map[MsgKey]map[Language]string{ "/compress\n 压缩会话上下文\n\n" + "/tts [always|voice_only]\n 查看/切换语音合成模式\n\n" + "/shell <命令>\n 执行 Shell 命令并返回结果\n\n" + + "/show <引用>\n 按引用查看文件、目录或代码片段\n\n" + "/dir [路径|reset]\n 查看、切换或重置 Agent 工作目录\n\n" + "/stop\n 停止当前执行\n\n" + "/cron [add|list|del|enable|disable]\n 管理定时任务\n\n" + @@ -1127,6 +1135,7 @@ var messages = map[MsgKey]map[Language]string{ MsgHelpToolsSection: { LangEnglish: "**Tools & Automation**\n" + "/shell — Run a shell command\n" + + "/show — View file / directory / snippet by reference\n" + "/dir [path|reset] — Show, switch, or reset work directory\n" + "/cron [add|list|del|...] — Scheduled tasks\n" + "/commands [add|del] — Custom commands\n" + @@ -1136,6 +1145,7 @@ var messages = map[MsgKey]map[Language]string{ "/stop — Stop current execution", LangChinese: "**工具与自动化**\n" + "/shell <命令> — 执行 Shell 命令\n" + + "/show <引用> — 按引用查看文件、目录或代码片段\n" + "/dir [路径|reset] — 查看、切换或重置工作目录\n" + "/cron [add|list|del|...] — 定时任务\n" + "/commands [add|del] — 自定义命令\n" + @@ -3428,6 +3438,48 @@ var messages = map[MsgKey]map[Language]string{ LangJapanese: "前へ", LangSpanish: "Anterior", }, + MsgShow: { + LangEnglish: "View file / directory / snippet by reference", + LangChinese: "按引用查看文件、目录或代码片段", + LangTraditionalChinese: "按引用查看檔案、目錄或程式碼片段", + LangJapanese: "参照からファイル・ディレクトリ・コード断片を表示", + LangSpanish: "Ver archivo/directorio/fragmento por referencia", + }, + MsgShowUsage: { + LangEnglish: "Usage: `/show `\nExample: `/show svc/recovery_session_reconciler.go:12`", + LangChinese: "用法: `/show <路径|路径:行号|路径:起止行|目录/>`\n示例: `/show svc/recovery_session_reconciler.go:12`", + LangTraditionalChinese: "用法: `/show <路徑|路徑:行號|路徑:起止行|目錄/>`\n範例: `/show svc/recovery_session_reconciler.go:12`", + LangJapanese: "使い方: `/show <パス|パス:行|パス:開始-終了|dir/>`\n例: `/show svc/recovery_session_reconciler.go:12`", + LangSpanish: "Uso: `/show `\nEjemplo: `/show svc/recovery_session_reconciler.go:12`", + }, + MsgShowParseError: { + LangEnglish: "❌ Cannot parse reference: `%s`", + LangChinese: "❌ 无法解析引用: `%s`", + LangTraditionalChinese: "❌ 無法解析引用: `%s`", + LangJapanese: "❌ 参照を解析できません: `%s`", + LangSpanish: "❌ No se puede interpretar la referencia: `%s`", + }, + MsgShowNotFound: { + LangEnglish: "❌ Referenced path does not exist: `%s`", + LangChinese: "❌ 引用路径不存在: `%s`", + LangTraditionalChinese: "❌ 引用路徑不存在: `%s`", + LangJapanese: "❌ 参照パスが存在しません: `%s`", + LangSpanish: "❌ La ruta referenciada no existe: `%s`", + }, + MsgShowDirWithLocation: { + LangEnglish: "❌ Directory references cannot include line information: `%s`", + LangChinese: "❌ 目录引用不能带行号信息: `%s`", + LangTraditionalChinese: "❌ 目錄引用不能帶行號資訊: `%s`", + LangJapanese: "❌ ディレクトリ参照に行情報は指定できません: `%s`", + LangSpanish: "❌ Una referencia de directorio no puede incluir líneas: `%s`", + }, + MsgShowReadFailed: { + LangEnglish: "❌ Failed to read reference: %s", + LangChinese: "❌ 读取引用失败: %s", + LangTraditionalChinese: "❌ 讀取引用失敗: %s", + LangJapanese: "❌ 参照の読み取りに失敗しました: %s", + LangSpanish: "❌ Error al leer la referencia: %s", + }, // Multi-workspace messages MsgWsNotEnabled: { diff --git a/core/progress_compact.go b/core/progress_compact.go index 8311795fa..31f4d7b5c 100644 --- a/core/progress_compact.go +++ b/core/progress_compact.go @@ -204,9 +204,10 @@ func inferLegacyEntryKind(entry string) ProgressCardEntryKind { // compactProgressWriter coalesces intermediate progress (thinking/tool-use) // into one editable message for platforms that support message updates. type compactProgressWriter struct { - ctx context.Context - platform Platform - replyCtx any + ctx context.Context + platform Platform + replyCtx any + transform func(string) string starter PreviewStarter updater MessageUpdater @@ -262,11 +263,12 @@ func SuppressStandaloneToolResultEvent(p Platform) bool { return progressStyleForPlatform(p) == progressStyleLegacy } -func newCompactProgressWriter(ctx context.Context, p Platform, replyCtx any, agentName string, lang Language) *compactProgressWriter { +func newCompactProgressWriter(ctx context.Context, p Platform, replyCtx any, agentName string, lang Language, transform func(string) string) *compactProgressWriter { w := &compactProgressWriter{ ctx: ctx, platform: p, replyCtx: replyCtx, + transform: transform, style: progressStyleForPlatform(p), state: ProgressCardStateRunning, agentName: normalizeProgressAgentLabel(agentName), @@ -358,6 +360,13 @@ func (w *compactProgressWriter) AppendStructured(item ProgressCardEntry, fallbac if fallback == "" { fallback = text } + switch item.Kind { + case ProgressEntryThinking, ProgressEntryError, ProgressEntryInfo: + if w.transform != nil { + text = w.transform(text) + fallback = w.transform(fallback) + } + } kind := item.Kind if kind == "" { kind = ProgressEntryInfo diff --git a/core/progress_compact_test.go b/core/progress_compact_test.go index dd79673f1..621d7706a 100644 --- a/core/progress_compact_test.go +++ b/core/progress_compact_test.go @@ -10,12 +10,12 @@ type suppressTestPlatform struct { style string } -func (s *suppressTestPlatform) Name() string { return "test" } -func (s *suppressTestPlatform) Start(MessageHandler) error { return nil } +func (s *suppressTestPlatform) Name() string { return "test" } +func (s *suppressTestPlatform) Start(MessageHandler) error { return nil } func (s *suppressTestPlatform) Reply(context.Context, any, string) error { return nil } -func (s *suppressTestPlatform) Send(context.Context, any, string) error { return nil } -func (s *suppressTestPlatform) Stop() error { return nil } -func (s *suppressTestPlatform) ProgressStyle() string { return s.style } +func (s *suppressTestPlatform) Send(context.Context, any, string) error { return nil } +func (s *suppressTestPlatform) Stop() error { return nil } +func (s *suppressTestPlatform) ProgressStyle() string { return s.style } func TestSuppressStandaloneToolResultEvent(t *testing.T) { if SuppressStandaloneToolResultEvent(&stubPlatformNoProgress{}) { @@ -35,11 +35,11 @@ func TestSuppressStandaloneToolResultEvent(t *testing.T) { // stubPlatformNoProgress is a minimal Platform without ProgressStyleProvider. type stubPlatformNoProgress struct{} -func (stubPlatformNoProgress) Name() string { return "plain" } -func (stubPlatformNoProgress) Start(MessageHandler) error { return nil } +func (stubPlatformNoProgress) Name() string { return "plain" } +func (stubPlatformNoProgress) Start(MessageHandler) error { return nil } func (stubPlatformNoProgress) Reply(context.Context, any, string) error { return nil } -func (stubPlatformNoProgress) Send(context.Context, any, string) error { return nil } -func (stubPlatformNoProgress) Stop() error { return nil } +func (stubPlatformNoProgress) Send(context.Context, any, string) error { return nil } +func (stubPlatformNoProgress) Stop() error { return nil } func TestBuildAndParseProgressCardPayload(t *testing.T) { payload := BuildProgressCardPayload([]string{" step1 ", "", "step2"}, true) @@ -115,3 +115,67 @@ func TestParseProgressCardPayloadRejectsInvalid(t *testing.T) { t.Fatal("expected parse failure for empty entries") } } + +func TestCompactProgressWriter_AppliesTransformToCardPayloadEntries(t *testing.T) { + p := &stubCompactProgressPlatform{ + stubPlatformEngine: stubPlatformEngine{n: "feishu"}, + style: "card", + supportPayload: true, + } + w := newCompactProgressWriter(context.Background(), p, "ctx", "codex", LangEnglish, func(s string) string { + return strings.ReplaceAll(s, "/root/code/demo/src/app.ts:42", "📄 `src/app.ts:42`") + }) + + if ok := w.AppendStructured(ProgressCardEntry{ + Kind: ProgressEntryThinking, + Text: "Inspect /root/code/demo/src/app.ts:42", + }, "Inspect /root/code/demo/src/app.ts:42"); !ok { + t.Fatal("AppendStructured() = false, want true") + } + + starts := p.getPreviewStarts() + if len(starts) != 1 { + t.Fatalf("preview starts = %d, want 1", len(starts)) + } + payload, ok := ParseProgressCardPayload(starts[0]) + if !ok { + t.Fatalf("ParseProgressCardPayload(%q) failed", starts[0]) + } + if len(payload.Items) != 1 { + t.Fatalf("payload items = %d, want 1", len(payload.Items)) + } + if got := payload.Items[0].Text; got != "Inspect 📄 `src/app.ts:42`" { + t.Fatalf("payload item text = %q, want transformed text", got) + } +} + +func TestCompactProgressWriter_DoesNotTransformToolResults(t *testing.T) { + p := &stubCompactProgressPlatform{ + stubPlatformEngine: stubPlatformEngine{n: "feishu"}, + style: "card", + supportPayload: true, + } + w := newCompactProgressWriter(context.Background(), p, "ctx", "codex", LangEnglish, func(s string) string { + return strings.ReplaceAll(s, "/root/code/demo/src/app.ts:42", "📄 `src/app.ts:42`") + }) + + raw := "/root/code/demo/src/app.ts:42" + if ok := w.AppendStructured(ProgressCardEntry{ + Kind: ProgressEntryToolResult, + Text: raw, + }, raw); !ok { + t.Fatal("AppendStructured() = false, want true") + } + + starts := p.getPreviewStarts() + if len(starts) != 1 { + t.Fatalf("preview starts = %d, want 1", len(starts)) + } + payload, ok := ParseProgressCardPayload(starts[0]) + if !ok { + t.Fatalf("ParseProgressCardPayload(%q) failed", starts[0]) + } + if got := payload.Items[0].Text; got != raw { + t.Fatalf("tool result text = %q, want raw %q", got, raw) + } +} diff --git a/core/reference_parse.go b/core/reference_parse.go new file mode 100644 index 000000000..97fafefaf --- /dev/null +++ b/core/reference_parse.go @@ -0,0 +1,196 @@ +package core + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "regexp" + "strings" +) + +type referenceKind string + +const ( + referenceKindUnknown referenceKind = "unknown" + referenceKindFile referenceKind = "file" + referenceKindDir referenceKind = "dir" +) + +type referenceLocationFormat string + +const ( + referenceLocationNone referenceLocationFormat = "" + referenceLocationColonLine referenceLocationFormat = "colon_line" + referenceLocationColonLineCol referenceLocationFormat = "colon_line_col" + referenceLocationColonRange referenceLocationFormat = "colon_line_range" + referenceLocationHashLine referenceLocationFormat = "hash_line" + referenceLocationHashLineCol referenceLocationFormat = "hash_line_col" +) + +type localReference struct { + kind referenceKind + raw string + pathOriginal string + pathAbs string + pathRel string + isRelative bool + locationFormat referenceLocationFormat + lineStart int + lineEnd int + column int +} + +var ( + reMarkdownLink = regexp.MustCompile(`\[([^\]]+)\]\(([^)\s]+)\)((?::\d+(?::\d+)?|:\d+-\d+)?)?`) + reHashLocation = regexp.MustCompile(`^(.*?)(#L(\d+)(?:C(\d+))?)$`) + reColonLineCol = regexp.MustCompile(`^(.*):(\d+):(\d+)$`) + reColonLineRange = regexp.MustCompile(`^(.*):(\d+)-(\d+)$`) + reColonLineOnly = regexp.MustCompile(`^(.*):(\d+)$`) +) + +func parseUserLocalReference(raw, workspaceDir string) (*localReference, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, fmt.Errorf("empty reference") + } + if match := reMarkdownLink.FindStringSubmatch(raw); len(match) >= 3 && match[0] == raw { + suffix := "" + if len(match) >= 4 { + suffix = match[3] + } + raw = match[2] + suffix + } + ref, ok := parseLocalReference(raw, workspaceDir) + if !ok { + return nil, fmt.Errorf("cannot parse local reference") + } + return ref, nil +} + +func parseLocalReference(raw, workspaceDir string) (*localReference, bool) { + raw = strings.TrimSpace(raw) + if raw == "" || isWebURL(raw) || strings.HasPrefix(raw, "//") { + return nil, false + } + ref := &localReference{raw: raw} + pathPart := raw + switch { + case reHashLocation.MatchString(pathPart): + m := reHashLocation.FindStringSubmatch(pathPart) + pathPart = m[1] + ref.lineStart = atoiSafe(m[3]) + ref.column = atoiSafe(m[4]) + if ref.column > 0 { + ref.locationFormat = referenceLocationHashLineCol + } else { + ref.locationFormat = referenceLocationHashLine + } + case reColonLineCol.MatchString(pathPart): + m := reColonLineCol.FindStringSubmatch(pathPart) + pathPart = m[1] + ref.lineStart = atoiSafe(m[2]) + ref.column = atoiSafe(m[3]) + ref.locationFormat = referenceLocationColonLineCol + case reColonLineRange.MatchString(pathPart): + m := reColonLineRange.FindStringSubmatch(pathPart) + pathPart = m[1] + ref.lineStart = atoiSafe(m[2]) + ref.lineEnd = atoiSafe(m[3]) + ref.locationFormat = referenceLocationColonRange + case reColonLineOnly.MatchString(pathPart): + m := reColonLineOnly.FindStringSubmatch(pathPart) + pathPart = m[1] + ref.lineStart = atoiSafe(m[2]) + ref.locationFormat = referenceLocationColonLine + } + if strings.HasPrefix(pathPart, "file://") { + u, err := url.Parse(pathPart) + if err != nil || u.Path == "" { + return nil, false + } + pathPart = u.Path + } + if !looksLikeLocalPath(pathPart) { + return nil, false + } + ref.pathOriginal = pathPart + ref.isRelative = !filepath.IsAbs(pathPart) + if ref.isRelative { + if workspaceDir != "" { + ref.pathAbs = filepath.Clean(filepath.Join(workspaceDir, pathPart)) + if rel, err := filepath.Rel(workspaceDir, ref.pathAbs); err == nil { + ref.pathRel = filepath.ToSlash(rel) + } + } + } else { + ref.pathAbs = filepath.Clean(pathPart) + if workspaceDir != "" { + if rel, err := filepath.Rel(workspaceDir, ref.pathAbs); err == nil { + ref.pathRel = filepath.ToSlash(rel) + } + } + } + ref.kind = inferReferenceKind(ref) + return ref, true +} + +func inferReferenceKind(ref *localReference) referenceKind { + if ref == nil { + return referenceKindUnknown + } + if ref.pathAbs != "" { + if info, err := os.Stat(ref.pathAbs); err == nil { + if info.IsDir() { + return referenceKindDir + } + return referenceKindFile + } + } + if ref.locationFormat != referenceLocationNone { + return referenceKindFile + } + if strings.HasSuffix(ref.pathOriginal, "/") { + return referenceKindDir + } + base := filepath.Base(strings.TrimSuffix(ref.pathOriginal, "/")) + if filepath.Ext(base) != "" { + return referenceKindFile + } + return referenceKindUnknown +} + +func looksLikeLocalPath(path string) bool { + if path == "" || strings.HasPrefix(path, "http://") || strings.HasPrefix(path, "https://") || strings.HasPrefix(path, "//") { + return false + } + switch { + case strings.HasPrefix(path, "/"): + return true + case strings.HasPrefix(path, "./"), strings.HasPrefix(path, "../"): + return true + case strings.Contains(path, "/"): + return true + default: + base := filepath.Base(path) + return strings.Contains(base, ".") + } +} + +func isWebURL(s string) bool { + return strings.HasPrefix(s, "http://") || strings.HasPrefix(s, "https://") +} + +func atoiSafe(s string) int { + if s == "" { + return 0 + } + var n int + for _, r := range s { + if r < '0' || r > '9' { + return 0 + } + n = n*10 + int(r-'0') + } + return n +} diff --git a/core/reference_render.go b/core/reference_render.go new file mode 100644 index 000000000..21f610eb0 --- /dev/null +++ b/core/reference_render.go @@ -0,0 +1,543 @@ +package core + +import ( + "fmt" + "path/filepath" + "regexp" + "sort" + "strings" + "unicode/utf8" +) + +type ReferenceRenderCfg struct { + NormalizeAgents []string + RenderPlatforms []string + DisplayPath string + MarkerStyle string + EnclosureStyle string +} + +type placeholderReplacement struct { + placeholder string + ref *localReference + keepText string +} + +var ( + reFenceBlock = regexp.MustCompile("(?s)```.*?```") + reInlineCodeSpan = regexp.MustCompile("`([^`\n]+)`") + reBareURL = regexp.MustCompile(`https?://[^\s<>()]+`) + reAbsOrFileRef = regexp.MustCompile(`file:///[^\s` + "`" + `<>\[\](),,、;;。!?!?]+|/[^\s` + "`" + `<>\[\](),,、;;。!?!?]+`) + reRelativeRef = regexp.MustCompile(`(?:\.\.?/|[A-Za-z0-9_.-]+/)[^\s` + "`" + `<>\[\](),,、;;。!?!?]+`) + reBasenameFileRef = regexp.MustCompile(`\b[A-Za-z0-9_.-]+\.[A-Za-z0-9_.-]+(?:#L\d+(?:C\d+)?|:\d+(?::\d+)?|:\d+-\d+)?\b`) +) + +func DefaultReferenceRenderCfg() ReferenceRenderCfg { + return ReferenceRenderCfg{ + DisplayPath: "dirname_basename", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } +} + +func normalizeReferenceRenderCfg(cfg ReferenceRenderCfg) ReferenceRenderCfg { + n := DefaultReferenceRenderCfg() + if strings.TrimSpace(cfg.DisplayPath) != "" { + n.DisplayPath = strings.ToLower(strings.TrimSpace(cfg.DisplayPath)) + } + if strings.TrimSpace(cfg.MarkerStyle) != "" { + n.MarkerStyle = strings.ToLower(strings.TrimSpace(cfg.MarkerStyle)) + } + if strings.TrimSpace(cfg.EnclosureStyle) != "" { + n.EnclosureStyle = strings.ToLower(strings.TrimSpace(cfg.EnclosureStyle)) + } + n.NormalizeAgents = normalizeReferenceScope(cfg.NormalizeAgents, supportedReferenceNormalizeAgents) + n.RenderPlatforms = normalizeReferenceScope(cfg.RenderPlatforms, supportedReferenceRenderPlatforms) + return n +} + +var supportedReferenceNormalizeAgents = []string{"codex", "claudecode"} +var supportedReferenceRenderPlatforms = []string{"feishu", "weixin"} + +func normalizeReferenceScope(values []string, supported []string) []string { + if len(values) == 0 { + return nil + } + supportedSet := make(map[string]struct{}, len(supported)) + for _, v := range supported { + supportedSet[v] = struct{}{} + } + hasAll := false + seen := map[string]struct{}{} + out := make([]string, 0, len(values)) + for _, v := range values { + key := strings.ToLower(strings.TrimSpace(v)) + if key == "" { + continue + } + if key == "all" { + hasAll = true + continue + } + if _, ok := supportedSet[key]; !ok { + continue + } + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, key) + } + if hasAll { + return append([]string(nil), supported...) + } + return out +} + +func (cfg ReferenceRenderCfg) renderEnabled(agentName, platformName string) bool { + if len(cfg.NormalizeAgents) == 0 || len(cfg.RenderPlatforms) == 0 { + return false + } + agentName = strings.ToLower(strings.TrimSpace(agentName)) + platformName = strings.ToLower(strings.TrimSpace(platformName)) + if !containsFolded(cfg.NormalizeAgents, agentName) { + return false + } + return containsFolded(cfg.RenderPlatforms, platformName) +} + +func containsFolded(values []string, want string) bool { + for _, v := range values { + if strings.EqualFold(strings.TrimSpace(v), want) { + return true + } + } + return false +} + +func TransformLocalReferences(text string, cfg ReferenceRenderCfg, agentName, platformName, workspaceDir string) string { + cfg = normalizeReferenceRenderCfg(cfg) + if !cfg.renderEnabled(agentName, platformName) || strings.TrimSpace(text) == "" { + return text + } + parts := splitWithMatches(text, reFenceBlock) + var out strings.Builder + for _, part := range parts { + if part.matched { + out.WriteString(part.text) + continue + } + out.WriteString(transformTextOutsideFence(part.text, cfg, workspaceDir)) + } + return out.String() +} + +func transformTextOutsideFence(text string, cfg ReferenceRenderCfg, workspaceDir string) string { + parts := splitWithMatches(text, reInlineCodeSpan) + replacements := make([]placeholderReplacement, 0) + var out strings.Builder + for _, part := range parts { + if !part.matched { + transformed, reps := transformNonCodeText(part.text, cfg, workspaceDir) + if len(replacements) > 0 && len(reps) > 0 { + offset := len(replacements) + for i := range reps { + oldPlaceholder := reps[i].placeholder + newPlaceholder := makeReferencePlaceholder(offset + i) + transformed = strings.ReplaceAll(transformed, oldPlaceholder, newPlaceholder) + reps[i].placeholder = newPlaceholder + } + } + out.WriteString(transformed) + replacements = append(replacements, reps...) + continue + } + match := reInlineCodeSpan.FindStringSubmatch(part.text) + if len(match) < 2 { + out.WriteString(part.text) + continue + } + ref, ok := parseLocalReference(match[1], workspaceDir) + if !ok { + out.WriteString(part.text) + continue + } + placeholder := makeReferencePlaceholder(len(replacements)) + replacements = append(replacements, placeholderReplacement{placeholder: placeholder, ref: ref}) + out.WriteString(placeholder) + } + return replaceReferencePlaceholders(out.String(), replacements, cfg) +} + +func transformNonCodeText(text string, cfg ReferenceRenderCfg, workspaceDir string) (string, []placeholderReplacement) { + replacements := make([]placeholderReplacement, 0) + text = replaceProtectedWebMarkdownLinks(text, &replacements) + text = replaceProtectedLinks(text, reBareURL, &replacements) + text = replaceMarkdownLinks(text, &replacements, workspaceDir) + text = replaceLocalReferenceCandidates(text, reAbsOrFileRef, &replacements, workspaceDir) + text = replaceLocalReferenceCandidates(text, reRelativeRef, &replacements, workspaceDir) + text = replaceLocalReferenceCandidates(text, reBasenameFileRef, &replacements, workspaceDir) + return text, replacements +} + +func replaceProtectedLinks(text string, re *regexp.Regexp, replacements *[]placeholderReplacement) string { + matches := re.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return text + } + var out strings.Builder + last := 0 + for _, m := range matches { + out.WriteString(text[last:m[0]]) + token := text[m[0]:m[1]] + placeholder := makeReferencePlaceholder(len(*replacements)) + *replacements = append(*replacements, placeholderReplacement{placeholder: placeholder, keepText: token}) + out.WriteString(placeholder) + last = m[1] + } + out.WriteString(text[last:]) + return out.String() +} + +func replaceProtectedWebMarkdownLinks(text string, replacements *[]placeholderReplacement) string { + matches := reMarkdownLink.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return text + } + var out strings.Builder + last := 0 + for _, m := range matches { + target := text[m[4]:m[5]] + if !isWebURL(target) { + continue + } + out.WriteString(text[last:m[0]]) + token := text[m[0]:m[1]] + placeholder := makeReferencePlaceholder(len(*replacements)) + *replacements = append(*replacements, placeholderReplacement{placeholder: placeholder, keepText: token}) + out.WriteString(placeholder) + last = m[1] + } + out.WriteString(text[last:]) + return out.String() +} + +func replaceMarkdownLinks(text string, replacements *[]placeholderReplacement, workspaceDir string) string { + matches := reMarkdownLink.FindAllStringSubmatchIndex(text, -1) + if len(matches) == 0 { + return text + } + var out strings.Builder + last := 0 + for _, m := range matches { + out.WriteString(text[last:m[0]]) + target := text[m[4]:m[5]] + suffix := "" + if m[6] >= 0 { + suffix = text[m[6]:m[7]] + } + ref, ok := parseLocalReference(target+suffix, workspaceDir) + if !ok { + out.WriteString(text[m[0]:m[1]]) + last = m[1] + continue + } + placeholder := makeReferencePlaceholder(len(*replacements)) + *replacements = append(*replacements, placeholderReplacement{placeholder: placeholder, ref: ref}) + out.WriteString(placeholder) + last = m[1] + } + out.WriteString(text[last:]) + return out.String() +} + +func replaceLocalReferenceCandidates(text string, re *regexp.Regexp, replacements *[]placeholderReplacement, workspaceDir string) string { + matches := re.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return text + } + var out strings.Builder + last := 0 + for _, m := range matches { + out.WriteString(text[last:m[0]]) + token := text[m[0]:m[1]] + if re == reAbsOrFileRef && !isValidAbsoluteReferenceBoundary(text, m[0]) { + out.WriteString(token) + last = m[1] + continue + } + if re == reRelativeRef && !isValidRelativeReferenceBoundary(text, m[0]) { + out.WriteString(token) + last = m[1] + continue + } + ref, ok := parseLocalReference(token, workspaceDir) + if !ok { + out.WriteString(token) + last = m[1] + continue + } + placeholder := makeReferencePlaceholder(len(*replacements)) + *replacements = append(*replacements, placeholderReplacement{placeholder: placeholder, ref: ref}) + out.WriteString(placeholder) + last = m[1] + } + out.WriteString(text[last:]) + return out.String() +} + +func isValidAbsoluteReferenceBoundary(text string, start int) bool { + if start <= 0 { + return true + } + prev, _ := utf8.DecodeLastRuneInString(text[:start]) + switch { + case prev == ' ', prev == '\n', prev == '\t', prev == '\r': + return true + case strings.ContainsRune("([<{\"'`、,,;;。!?!?::", prev): + return true + default: + return false + } +} + +func isValidRelativeReferenceBoundary(text string, start int) bool { + if start <= 0 { + return true + } + prev, _ := utf8.DecodeLastRuneInString(text[:start]) + switch { + case prev == ' ', prev == '\n', prev == '\t', prev == '\r': + return true + case strings.ContainsRune("([<{\"'`、,,;;。!?!?::", prev): + return true + default: + return false + } +} + +func replaceReferencePlaceholders(text string, replacements []placeholderReplacement, cfg ReferenceRenderCfg) string { + if len(replacements) == 0 { + return text + } + basenameCounts := make(map[string]int) + for _, rep := range replacements { + if rep.ref == nil { + continue + } + base := refBaseName(rep.ref) + if base != "" { + basenameCounts[base]++ + } + } + sort.SliceStable(replacements, func(i, j int) bool { + return len(replacements[i].placeholder) > len(replacements[j].placeholder) + }) + for _, rep := range replacements { + replacement := rep.keepText + if rep.ref != nil { + replacement = renderLocalReference(rep.ref, cfg, basenameCounts) + } + text = strings.ReplaceAll(text, rep.placeholder, replacement) + } + return text +} + +type splitPart struct { + text string + matched bool +} + +func splitWithMatches(text string, re *regexp.Regexp) []splitPart { + matches := re.FindAllStringIndex(text, -1) + if len(matches) == 0 { + return []splitPart{{text: text}} + } + parts := make([]splitPart, 0, len(matches)*2+1) + last := 0 + for _, m := range matches { + if m[0] > last { + parts = append(parts, splitPart{text: text[last:m[0]]}) + } + parts = append(parts, splitPart{text: text[m[0]:m[1]], matched: true}) + last = m[1] + } + if last < len(text) { + parts = append(parts, splitPart{text: text[last:]}) + } + return parts +} + +func makeReferencePlaceholder(idx int) string { + return fmt.Sprintf("\x00REF_%03d\x00", idx) +} + +func refBaseName(ref *localReference) string { + if ref == nil { + return "" + } + p := referenceDisplaySource(ref, "basename") + if p == "" { + return "" + } + return strings.TrimSuffix(p, "/") +} + +func renderLocalReference(ref *localReference, cfg ReferenceRenderCfg, basenameCounts map[string]int) string { + body := referenceDisplaySource(ref, cfg.DisplayPath) + if cfg.DisplayPath == "smart" { + base := refBaseName(ref) + if basenameCounts[base] <= 1 { + body = referenceDisplaySource(ref, "basename") + } else { + body = referenceDisplaySource(ref, "dirname_basename") + if body == base { + body = referenceDisplaySource(ref, "relative") + } + } + } + body += renderReferenceLocation(ref) + body = applyReferenceEnclosure(cfg.EnclosureStyle, body) + return applyReferenceMarker(cfg.MarkerStyle, ref.kind, body) +} + +func referenceDisplaySource(ref *localReference, mode string) string { + mode = strings.ToLower(strings.TrimSpace(mode)) + switch mode { + case "absolute": + if ref.pathAbs != "" { + return appendDirSuffix(ref.pathAbs, ref.kind) + } + return appendDirSuffix(cleanDisplayPath(ref.pathOriginal), ref.kind) + case "relative": + if ref.pathRel == "." { + if ref.kind == referenceKindDir { + return "./" + } + return "." + } + if rel := sanitizeRelativeDisplay(ref.pathRel); rel != "" { + return appendDirSuffix(rel, ref.kind) + } + if ref.isRelative { + return appendDirSuffix(cleanDisplayPath(ref.pathOriginal), ref.kind) + } + if ref.pathAbs != "" { + return appendDirSuffix(ref.pathAbs, ref.kind) + } + return appendDirSuffix(cleanDisplayPath(ref.pathOriginal), ref.kind) + case "basename": + return appendDirSuffix(pathTail(ref, 1), ref.kind) + case "dirname_basename": + return appendDirSuffix(pathTail(ref, 2), ref.kind) + case "smart": + return appendDirSuffix(pathTail(ref, 1), ref.kind) + default: + return appendDirSuffix(pathTail(ref, 2), ref.kind) + } +} + +func sanitizeRelativeDisplay(rel string) string { + rel = filepath.ToSlash(strings.TrimSpace(rel)) + if rel == "" || rel == "." || rel == ".." || strings.HasPrefix(rel, "../") { + return "" + } + return rel +} + +func pathTail(ref *localReference, segs int) string { + source := sanitizeRelativeDisplay(ref.pathRel) + if source == "" { + if ref.isRelative { + source = cleanDisplayPath(ref.pathOriginal) + } else if ref.pathAbs != "" { + source = filepath.ToSlash(ref.pathAbs) + } else { + source = cleanDisplayPath(ref.pathOriginal) + } + } + source = strings.TrimSuffix(source, "/") + parts := strings.Split(filepath.ToSlash(source), "/") + if len(parts) == 0 { + return source + } + if segs <= 0 || len(parts) <= segs { + return source + } + return strings.Join(parts[len(parts)-segs:], "/") +} + +func cleanDisplayPath(path string) string { + if path == "" { + return "" + } + path = filepath.ToSlash(path) + path = strings.TrimPrefix(path, "./") + return strings.TrimSpace(path) +} + +func appendDirSuffix(path string, kind referenceKind) string { + path = filepath.ToSlash(strings.TrimSpace(path)) + if path == "" { + return path + } + if kind == referenceKindDir && !strings.HasSuffix(path, "/") { + return path + "/" + } + return strings.TrimSuffix(path, "/") +} + +func renderReferenceLocation(ref *localReference) string { + switch ref.locationFormat { + case referenceLocationColonLine: + return fmt.Sprintf(":%d", ref.lineStart) + case referenceLocationColonLineCol: + return fmt.Sprintf(":%d:%d", ref.lineStart, ref.column) + case referenceLocationColonRange: + return fmt.Sprintf(":%d-%d", ref.lineStart, ref.lineEnd) + case referenceLocationHashLine: + return fmt.Sprintf("#L%d", ref.lineStart) + case referenceLocationHashLineCol: + return fmt.Sprintf("#L%dC%d", ref.lineStart, ref.column) + default: + return "" + } +} + +func applyReferenceMarker(style string, kind referenceKind, body string) string { + switch strings.ToLower(strings.TrimSpace(style)) { + case "ascii": + if kind == referenceKindDir { + return "[DIR] " + body + } + if kind == referenceKindFile { + return "[FILE] " + body + } + return body + case "emoji": + if kind == referenceKindDir { + return "📁 " + body + } + if kind == referenceKindFile { + return "📄 " + body + } + return body + default: + return body + } +} + +func applyReferenceEnclosure(style, body string) string { + switch strings.ToLower(strings.TrimSpace(style)) { + case "bracket": + return "[" + body + "]" + case "angle": + return "<" + body + ">" + case "fullwidth": + return "【" + body + "】" + case "code": + return "`" + body + "`" + default: + return body + } +} diff --git a/core/reference_render_test.go b/core/reference_render_test.go new file mode 100644 index 000000000..1cc89fb08 --- /dev/null +++ b/core/reference_render_test.go @@ -0,0 +1,218 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestTransformLocalReferences_DisabledWithoutNormalizeAgents(t *testing.T) { + cfg := ReferenceRenderCfg{ + RenderPlatforms: []string{"feishu"}, + DisplayPath: "basename", + MarkerStyle: "none", + EnclosureStyle: "none", + } + input := "See /root/code/demo/src/app.ts:42" + got := TransformLocalReferences(input, cfg, "codex", "feishu", "/root/code/demo") + if got != input { + t.Fatalf("TransformLocalReferences() = %q, want unchanged %q", got, input) + } +} + +func TestTransformLocalReferences_UsesAllScopes(t *testing.T) { + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"all"}, + RenderPlatforms: []string{"all"}, + DisplayPath: "basename", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + got := TransformLocalReferences("See /root/code/demo/src/app.ts:42", cfg, "codex", "feishu", "/root/code/demo") + if !strings.Contains(got, "📄 `app.ts:42`") { + t.Fatalf("TransformLocalReferences() = %q, want rendered basename reference", got) + } +} + +func TestTransformLocalReferences_PreservesWebMarkdownLinks(t *testing.T) { + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "basename", + MarkerStyle: "none", + EnclosureStyle: "none", + } + input := "Docs: [OpenAI](https://openai.com/) and [app.ts](/root/code/demo/src/app.ts#L42)" + got := TransformLocalReferences(input, cfg, "codex", "feishu", "/root/code/demo") + if !strings.Contains(got, "[OpenAI](https://openai.com/)") { + t.Fatalf("TransformLocalReferences() = %q, want web link preserved", got) + } + if !strings.Contains(got, "app.ts#L42") { + t.Fatalf("TransformLocalReferences() = %q, want local hash-line reference rendered", got) + } +} + +func TestTransformLocalReferences_PreservesInlineCodePathRange(t *testing.T) { + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"claudecode"}, + RenderPlatforms: []string{"weixin"}, + DisplayPath: "dirname_basename", + MarkerStyle: "ascii", + EnclosureStyle: "code", + } + got := TransformLocalReferences("Inspect `/root/.claude/settings.json:5-10` next.", cfg, "claudecode", "weixin", "/root") + want := "[FILE] `.claude/settings.json:5-10`" + if !strings.Contains(got, want) { + t.Fatalf("TransformLocalReferences() = %q, want substring %q", got, want) + } +} + +func TestTransformLocalReferences_PreservesWebMarkdownLinksAfterInlineCodeReference(t *testing.T) { + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"claudecode"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + input := "`/root/code/.claude/settings.json:5-10`\n[OpenAI](https://openai.com/)" + got := TransformLocalReferences(input, cfg, "claudecode", "feishu", "/root/code") + want := "📄 `.claude/settings.json:5-10`\n[OpenAI](https://openai.com/)" + if got != want { + t.Fatalf("TransformLocalReferences() = %q, want %q", got, want) + } +} + +func TestTransformLocalReferences_SmartDisplayFallsBackOnBasenameCollision(t *testing.T) { + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "smart", + MarkerStyle: "none", + EnclosureStyle: "none", + } + input := "Compare /root/code/demo/src/app.ts and /root/code/demo/tests/app.ts" + got := TransformLocalReferences(input, cfg, "codex", "feishu", "/root/code/demo") + if !strings.Contains(got, "src/app.ts") || !strings.Contains(got, "tests/app.ts") { + t.Fatalf("TransformLocalReferences() = %q, want dirname+basename for both colliding refs", got) + } +} + +func TestTransformLocalReferences_RelativeDisplayUsesWorkspace(t *testing.T) { + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + got := TransformLocalReferences("Look at /root/code/demo/src/app.ts:42:7", cfg, "codex", "feishu", "/root/code/demo") + want := "📄 `src/app.ts:42:7`" + if !strings.Contains(got, want) { + t.Fatalf("TransformLocalReferences() = %q, want substring %q", got, want) + } +} + +func TestTransformLocalReferences_RelativeInputIsNotSplitByAbsoluteMatcher(t *testing.T) { + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + input := "See lean-steward/src/lean_topo_steward/prompting/instructions/global_instructions.py:42" + got := TransformLocalReferences(input, cfg, "codex", "feishu", "/root/code") + want := "See 📄 `lean-steward/src/lean_topo_steward/prompting/instructions/global_instructions.py:42`" + if got != want { + t.Fatalf("TransformLocalReferences() = %q, want %q", got, want) + } +} + +func TestTransformLocalReferences_ChineseListSeparatorsDoNotMergeCandidates(t *testing.T) { + workspace := t.TempDir() + filePath := filepath.Join(workspace, "demo-repo", "README") + profileDir := filepath.Join(workspace, "demo-repo", "src", "components", "profile") + profileExtDir := filepath.Join(workspace, "demo-repo", "src", "components", "profile.ts") + specDir := filepath.Join(workspace, "demo-repo", "docs", "spec.v1") + if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { + t.Fatalf("MkdirAll(file dir) error: %v", err) + } + if err := os.WriteFile(filePath, []byte("readme"), 0o644); err != nil { + t.Fatalf("WriteFile() error: %v", err) + } + for _, dir := range []string{profileDir, profileExtDir, specDir} { + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatalf("MkdirAll(%q) error: %v", dir, err) + } + } + + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"claudecode"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + input := "第 1 步:正在处理路径 demo-repo/README、" + profileDir + "、" + profileExtDir + "、" + specDir + "。" + got := TransformLocalReferences(input, cfg, "claudecode", "feishu", workspace) + want := "第 1 步:正在处理路径 📄 `demo-repo/README`、📁 `demo-repo/src/components/profile/`、📁 `demo-repo/src/components/profile.ts/`、📁 `demo-repo/docs/spec.v1/`。" + if got != want { + t.Fatalf("TransformLocalReferences() = %q, want %q", got, want) + } +} + +func TestTransformLocalReferences_ExistingDirectoryWithoutTrailingSlashIsDir(t *testing.T) { + workspace := t.TempDir() + dirPath := filepath.Join(workspace, "demo-repo", "src", "components") + if err := os.MkdirAll(dirPath, 0o755); err != nil { + t.Fatalf("MkdirAll() error: %v", err) + } + + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + got := TransformLocalReferences("Dir "+dirPath, cfg, "codex", "feishu", workspace) + want := "Dir 📁 `demo-repo/src/components/`" + if got != want { + t.Fatalf("TransformLocalReferences() = %q, want %q", got, want) + } +} + +func TestTransformLocalReferences_WorkspaceRootDisplaysAsRelativeRoot(t *testing.T) { + workspace := t.TempDir() + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + got := TransformLocalReferences("Root "+workspace, cfg, "codex", "feishu", workspace) + want := "Root 📁 `./`" + if got != want { + t.Fatalf("TransformLocalReferences() = %q, want %q", got, want) + } +} + +func TestTransformLocalReferences_UnknownNoExtPathKeepsNoMarker(t *testing.T) { + workspace := t.TempDir() + unknown := filepath.Join(workspace, "mysterypath") + cfg := ReferenceRenderCfg{ + NormalizeAgents: []string{"codex"}, + RenderPlatforms: []string{"feishu"}, + DisplayPath: "relative", + MarkerStyle: "emoji", + EnclosureStyle: "code", + } + got := TransformLocalReferences("Unknown "+unknown, cfg, "codex", "feishu", workspace) + want := "Unknown `mysterypath`" + if got != want { + t.Fatalf("TransformLocalReferences() = %q, want %q", got, want) + } +} diff --git a/core/reference_show.go b/core/reference_show.go new file mode 100644 index 000000000..8d8150cba --- /dev/null +++ b/core/reference_show.go @@ -0,0 +1,314 @@ +package core + +import ( + "bufio" + "fmt" + "os" + "path/filepath" + "strings" +) + +const ( + defaultShowHeadLines = 80 + defaultShowContextLines = 8 + defaultShowMaxRange = 120 + defaultShowMaxEntries = 50 +) + +type referenceViewMode string + +const ( + referenceViewFileHead referenceViewMode = "file_head" + referenceViewContext referenceViewMode = "context" + referenceViewRange referenceViewMode = "range" + referenceViewDir referenceViewMode = "dir" +) + +type referenceViewRequest struct { + Ref *localReference + Mode referenceViewMode + Window int + MaxLines int + MaxEntries int +} + +func buildReferenceViewRequest(rawRef, workspaceDir string) (*referenceViewRequest, error) { + ref, err := parseUserLocalReference(rawRef, workspaceDir) + if err != nil { + return nil, err + } + if ref.kind == referenceKindDir && ref.locationFormat != referenceLocationNone { + return nil, fmt.Errorf("directory reference cannot carry a location") + } + req := &referenceViewRequest{ + Ref: ref, + Window: defaultShowContextLines, + MaxLines: defaultShowMaxRange, + MaxEntries: defaultShowMaxEntries, + } + switch { + case ref.kind == referenceKindDir: + req.Mode = referenceViewDir + case ref.locationFormat == referenceLocationColonRange: + req.Mode = referenceViewRange + case ref.locationFormat != referenceLocationNone: + req.Mode = referenceViewContext + default: + req.Mode = referenceViewFileHead + req.MaxLines = defaultShowHeadLines + } + return req, nil +} + +func renderReferenceView(req *referenceViewRequest) (string, error) { + if req == nil || req.Ref == nil { + return "", fmt.Errorf("nil view request") + } + path := req.Ref.pathAbs + if path == "" { + path = req.Ref.pathOriginal + } + if path == "" { + return "", fmt.Errorf("empty path") + } + info, err := os.Stat(path) + if err != nil { + if os.IsNotExist(err) { + return "", fmt.Errorf("path does not exist") + } + return "", err + } + if info.IsDir() { + if req.Ref.locationFormat != referenceLocationNone { + return "", fmt.Errorf("directory reference cannot carry a location") + } + return renderReferenceDir(path, req) + } + return renderReferenceFile(path, req) +} + +func renderReferenceFile(path string, req *referenceViewRequest) (string, error) { + var ( + lines []string + truncated bool + err error + note string + ) + + switch req.Mode { + case referenceViewContext: + lines, _, err = readFileContext(path, req.Ref.lineStart, req.Window, req.Window, req.MaxLines) + case referenceViewRange: + lines, truncated, err = readFileRange(path, req.Ref.lineStart, req.Ref.lineEnd, req.MaxLines) + if truncated { + note = fmt.Sprintf("Only showing the first %d lines of the requested range.", req.MaxLines) + } + default: + lines, truncated, err = readFileHead(path, req.MaxLines) + if truncated { + note = fmt.Sprintf("Only showing the first %d lines.", req.MaxLines) + } + } + if err != nil { + return "", err + } + title := showReferenceTitle(req.Ref) + if title == "" { + title = cleanDisplayPath(path) + } + var sb strings.Builder + sb.WriteString(title) + if note != "" { + sb.WriteString("\n") + sb.WriteString(note) + } + sb.WriteString("\n```") + if lang := codeFenceLanguage(path); lang != "" { + sb.WriteString(lang) + } + sb.WriteString("\n") + if len(lines) > 0 { + sb.WriteString(strings.Join(lines, "\n")) + sb.WriteString("\n") + } + sb.WriteString("```") + return sb.String(), nil +} + +func renderReferenceDir(path string, req *referenceViewRequest) (string, error) { + entries, truncated, err := readDirEntries(path, req.MaxEntries) + if err != nil { + return "", err + } + title := showReferenceTitle(req.Ref) + if title == "" { + title = "📁 " + appendDirSuffix(cleanDisplayPath(path), referenceKindDir) + } + var sb strings.Builder + sb.WriteString(title) + if len(entries) == 0 { + sb.WriteString("\n(empty)") + return sb.String(), nil + } + sb.WriteString("\n") + for _, entry := range entries { + sb.WriteString("- ") + sb.WriteString(entry) + sb.WriteString("\n") + } + if truncated { + sb.WriteString(fmt.Sprintf("\nOnly showing the first %d entries.", req.MaxEntries)) + } + return strings.TrimRight(sb.String(), "\n"), nil +} + +func showReferenceTitle(ref *localReference) string { + if ref == nil { + return "" + } + body := referenceDisplaySource(ref, "relative") + renderReferenceLocation(ref) + return applyReferenceMarker("emoji", ref.kind, body) +} + +func readFileHead(path string, maxLines int) ([]string, bool, error) { + if maxLines <= 0 { + maxLines = defaultShowHeadLines + } + f, err := os.Open(path) + if err != nil { + return nil, false, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + lines := make([]string, 0, maxLines) + truncated := false + for scanner.Scan() { + if len(lines) >= maxLines { + truncated = true + break + } + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, false, err + } + return lines, truncated, nil +} + +func readFileRange(path string, start, end, maxLines int) ([]string, bool, error) { + if start <= 0 { + return nil, false, fmt.Errorf("invalid start line") + } + if end <= 0 || end < start { + return nil, false, fmt.Errorf("invalid end line") + } + if maxLines <= 0 { + maxLines = defaultShowMaxRange + } + + f, err := os.Open(path) + if err != nil { + return nil, false, err + } + defer f.Close() + + scanner := bufio.NewScanner(f) + buf := make([]byte, 0, 64*1024) + scanner.Buffer(buf, 1024*1024) + + lines := make([]string, 0, minInt(end-start+1, maxLines)) + lineNo := 0 + truncated := false + for scanner.Scan() { + lineNo++ + if lineNo < start { + continue + } + if lineNo > end { + break + } + if len(lines) >= maxLines { + truncated = true + break + } + lines = append(lines, scanner.Text()) + } + if err := scanner.Err(); err != nil { + return nil, false, err + } + return lines, truncated, nil +} + +func readFileContext(path string, line, before, after, maxLines int) ([]string, bool, error) { + if line <= 0 { + return nil, false, fmt.Errorf("invalid line") + } + start := line - before + if start < 1 { + start = 1 + } + end := line + after + return readFileRange(path, start, end, maxLines) +} + +func readDirEntries(path string, maxEntries int) ([]string, bool, error) { + if maxEntries <= 0 { + maxEntries = defaultShowMaxEntries + } + entries, err := os.ReadDir(path) + if err != nil { + return nil, false, err + } + out := make([]string, 0, minInt(len(entries), maxEntries)) + truncated := false + for i, entry := range entries { + if i >= maxEntries { + truncated = true + break + } + name := entry.Name() + if entry.IsDir() { + name += "/" + } + out = append(out, name) + } + return out, truncated, nil +} + +func codeFenceLanguage(path string) string { + switch strings.ToLower(filepath.Ext(path)) { + case ".go": + return "go" + case ".ts": + return "ts" + case ".tsx": + return "tsx" + case ".js": + return "js" + case ".jsx": + return "jsx" + case ".py": + return "python" + case ".md": + return "markdown" + case ".json": + return "json" + case ".yaml", ".yml": + return "yaml" + case ".sh": + return "bash" + default: + return "" + } +} + +func minInt(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/core/reference_show_test.go b/core/reference_show_test.go new file mode 100644 index 000000000..4bf5b9598 --- /dev/null +++ b/core/reference_show_test.go @@ -0,0 +1,130 @@ +package core + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestBuildReferenceViewRequest_ModeSelection(t *testing.T) { + ws := t.TempDir() + file := filepath.Join(ws, "svc", "handler.go") + dir := filepath.Join(ws, "docs", "spec.v1") + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + + cases := []struct { + name string + raw string + mode referenceViewMode + }{ + {name: "file", raw: file, mode: referenceViewFileHead}, + {name: "line", raw: file + ":12", mode: referenceViewContext}, + {name: "linecol", raw: file + ":12:2", mode: referenceViewContext}, + {name: "range", raw: file + ":8-17", mode: referenceViewRange}, + {name: "hash", raw: file + "#L12", mode: referenceViewContext}, + {name: "markdown", raw: "[handler.go](" + file + "#L12)", mode: referenceViewContext}, + {name: "dir", raw: dir, mode: referenceViewDir}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req, err := buildReferenceViewRequest(tc.raw, ws) + if err != nil { + t.Fatalf("buildReferenceViewRequest error: %v", err) + } + if req.Mode != tc.mode { + t.Fatalf("mode = %q, want %q", req.Mode, tc.mode) + } + }) + } +} + +func TestBuildReferenceViewRequest_DirectoryWithLocationFails(t *testing.T) { + ws := t.TempDir() + dir := filepath.Join(ws, "docs", "spec.v1") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if _, err := buildReferenceViewRequest(dir+":12", ws); err == nil { + t.Fatal("expected error for directory location reference") + } +} + +func TestRenderReferenceView_FileHeadAndContext(t *testing.T) { + ws := t.TempDir() + file := filepath.Join(ws, "svc", "handler.go") + if err := os.MkdirAll(filepath.Dir(file), 0o755); err != nil { + t.Fatal(err) + } + content := strings.Join([]string{ + "package svc", + "", + "func one() {}", + "func two() {}", + "func three() {}", + }, "\n") + if err := os.WriteFile(file, []byte(content), 0o644); err != nil { + t.Fatal(err) + } + + headReq, err := buildReferenceViewRequest(file, ws) + if err != nil { + t.Fatal(err) + } + head, err := renderReferenceView(headReq) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(head, "```go") { + t.Fatalf("head output = %q, want go code fence", head) + } + if !strings.Contains(head, "package svc") { + t.Fatalf("head output = %q, want file content", head) + } + + ctxReq, err := buildReferenceViewRequest(file+":4", ws) + if err != nil { + t.Fatal(err) + } + ctx, err := renderReferenceView(ctxReq) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(ctx, "func two() {}") { + t.Fatalf("context output = %q, want nearby line", ctx) + } +} + +func TestRenderReferenceView_DirectoryList(t *testing.T) { + ws := t.TempDir() + dir := filepath.Join(ws, "docs", "spec.v1") + if err := os.MkdirAll(filepath.Join(dir, "subdir"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "alpha.md"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + req, err := buildReferenceViewRequest(dir, ws) + if err != nil { + t.Fatal(err) + } + out, err := renderReferenceView(req) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(out, "📁 docs/spec.v1/") { + t.Fatalf("directory output = %q, want title", out) + } + if !strings.Contains(out, "- alpha.md") || !strings.Contains(out, "- subdir/") { + t.Fatalf("directory output = %q, want entries", out) + } +} diff --git a/core/streaming.go b/core/streaming.go index aa667df5a..73158e34c 100644 --- a/core/streaming.go +++ b/core/streaming.go @@ -34,10 +34,11 @@ func DefaultStreamPreviewCfg() StreamPreviewCfg { type streamPreview struct { mu sync.Mutex - cfg StreamPreviewCfg - platform Platform - replyCtx any - ctx context.Context + cfg StreamPreviewCfg + platform Platform + replyCtx any + ctx context.Context + transform func(string) string fullText string // accumulated full text so far lastSentText string // what was last successfully sent to the platform @@ -72,12 +73,13 @@ type PreviewFinishPreference interface { KeepPreviewOnFinish() bool } -func newStreamPreview(cfg StreamPreviewCfg, p Platform, replyCtx any, ctx context.Context) *streamPreview { +func newStreamPreview(cfg StreamPreviewCfg, p Platform, replyCtx any, ctx context.Context, transform func(string) string) *streamPreview { return &streamPreview{ cfg: cfg, platform: p, replyCtx: replyCtx, ctx: ctx, + transform: transform, timerStop: make(chan struct{}), } } @@ -166,6 +168,9 @@ func (sp *streamPreview) cancelTimerLocked() { // flushLocked sends the current preview text to the platform. Must hold sp.mu. func (sp *streamPreview) flushLocked(text string) { + if sp.transform != nil { + text = sp.transform(text) + } if text == sp.lastSentText || text == "" { return } @@ -232,6 +237,9 @@ func (sp *streamPreview) freeze() { text = string([]rune(text)[:maxChars]) + "…" } if text != "" { + if sp.transform != nil { + text = sp.transform(text) + } _ = updater.UpdateMessage(sp.ctx, sp.previewMsgID, text) } } @@ -282,6 +290,9 @@ func (sp *streamPreview) finish(finalText string) bool { close(sp.timerStop) } + if sp.transform != nil { + finalText = sp.transform(finalText) + } if sp.previewMsgID == nil || sp.degraded { if sp.previewMsgID != nil && sp.degraded { if cleaner, ok := sp.platform.(PreviewCleaner); ok { diff --git a/core/streaming_test.go b/core/streaming_test.go index 96be999a9..94e994e6b 100644 --- a/core/streaming_test.go +++ b/core/streaming_test.go @@ -2,6 +2,7 @@ package core import ( "context" + "strings" "sync" "testing" "time" @@ -48,7 +49,7 @@ func TestStreamPreview_BasicFlow(t *testing.T) { MaxChars: 500, } - sp := newStreamPreview(cfg, mp, "ctx", context.Background()) + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), nil) if !sp.canPreview() { t.Fatal("should be able to preview") @@ -75,7 +76,7 @@ func TestStreamPreview_ThrottlesUpdates(t *testing.T) { MaxChars: 500, } - sp := newStreamPreview(cfg, mp, "ctx", context.Background()) + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), nil) // Rapid-fire small appends for i := 0; i < 10; i++ { @@ -105,7 +106,7 @@ func TestStreamPreview_MaxChars(t *testing.T) { MaxChars: 10, } - sp := newStreamPreview(cfg, mp, "ctx", context.Background()) + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), nil) sp.appendText("This is a very long text that exceeds max chars limit") time.Sleep(100 * time.Millisecond) @@ -134,7 +135,7 @@ func TestStreamPreview_Disabled(t *testing.T) { mp := &mockUpdaterPlatform{} cfg := StreamPreviewCfg{Enabled: false} - sp := newStreamPreview(cfg, mp, "ctx", context.Background()) + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), nil) if sp.canPreview() { t.Error("should not be able to preview when disabled") } @@ -157,7 +158,7 @@ func TestStreamPreview_FinishInPlace(t *testing.T) { MaxChars: 500, } - sp := newStreamPreview(cfg, mp, "ctx", context.Background()) + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), nil) sp.appendText("Hello World") time.Sleep(100 * time.Millisecond) @@ -203,7 +204,7 @@ func TestStreamPreview_FreezeDeletesOnFinish(t *testing.T) { MaxChars: 500, } - sp := newStreamPreview(cfg, mp, "ctx", context.Background()) + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), nil) sp.appendText("Hello World") time.Sleep(100 * time.Millisecond) @@ -228,7 +229,7 @@ func TestStreamPreview_NonUpdaterPlatform(t *testing.T) { p := &stubPlatformEngine{n: "plain"} cfg := DefaultStreamPreviewCfg() - sp := newStreamPreview(cfg, p, "ctx", context.Background()) + sp := newStreamPreview(cfg, p, "ctx", context.Background(), nil) if sp.canPreview() { t.Error("should not preview on non-updater platform") } @@ -243,7 +244,7 @@ func TestStreamPreview_DiscardDeletesPreview(t *testing.T) { MaxChars: 500, } - sp := newStreamPreview(cfg, mp, "ctx", context.Background()) + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), nil) sp.appendText("Hello World") time.Sleep(100 * time.Millisecond) @@ -271,7 +272,7 @@ func TestStreamPreview_FinishKeepsPreviewWhenPlatformPrefersInPlaceFinalize(t *t MaxChars: 500, } - sp := newStreamPreview(cfg, mp, "ctx", context.Background()) + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), nil) sp.appendText("Hello World") time.Sleep(100 * time.Millisecond) @@ -292,3 +293,35 @@ func TestStreamPreview_FinishKeepsPreviewWhenPlatformPrefersInPlaceFinalize(t *t t.Fatalf("messages = %#v, want final update in place", msgs) } } + +func TestStreamPreview_AppliesTransform(t *testing.T) { + mp := &mockUpdaterPlatform{} + cfg := StreamPreviewCfg{ + Enabled: true, + IntervalMs: 50, + MinDeltaChars: 1, + MaxChars: 500, + } + + sp := newStreamPreview(cfg, mp, "ctx", context.Background(), func(s string) string { + return strings.ReplaceAll(s, "/root/code/demo/src/app.ts:42", "📄 `src/app.ts:42`") + }) + sp.appendText("See /root/code/demo/src/app.ts:42") + time.Sleep(100 * time.Millisecond) + + ok := sp.finish("Final /root/code/demo/src/app.ts:42") + if !ok { + t.Fatal("finish should succeed when preview is active") + } + + msgs := mp.getMessages() + if len(msgs) < 2 { + t.Fatalf("messages = %#v, want preview start and final update", msgs) + } + if got := msgs[0]; got != "start:See 📄 `src/app.ts:42`" { + t.Fatalf("start message = %q, want transformed preview start", got) + } + if got := msgs[len(msgs)-1]; got != "update:Final 📄 `src/app.ts:42`" { + t.Fatalf("final message = %q, want transformed final preview", got) + } +} diff --git a/docs/usage.zh-CN.md b/docs/usage.zh-CN.md index 532dea2f8..fa2ef64cb 100644 --- a/docs/usage.zh-CN.md +++ b/docs/usage.zh-CN.md @@ -9,6 +9,7 @@ cc-connect 完整功能使用指南。 - [API Provider 管理](#api-provider-管理) - [模型选择](#模型选择) - [工作目录切换(`/dir`、`/cd`)](#工作目录切换dircd) +- [引用查看(`/show`)](#引用查看show) - [飞书配置 CLI](#飞书配置-cli) - [微信个人号配置 CLI](#微信个人号配置-cli) - [Claude Code Router 集成](#claude-code-router-集成) @@ -40,6 +41,7 @@ cc-connect 完整功能使用指南。 | `/provider [...]` | 管理 API Provider | | `/model [switch ]` | 列出可用模型或按别名切换 | | `/dir [路径]` | 查看或切换 Agent 工作目录 | +| `/show <引用>` | 按引用查看文件、目录或代码片段 | | `/allow <工具名>` | 预授权工具 | | `/reasoning [等级]` | 查看或切换推理强度(Codex)| | `/mode [名称]` | 查看或切换权限模式 | @@ -272,6 +274,149 @@ alias = "spark" --- +## 本地引用展示配置(`[projects.references]`) + +可选启用对 Agent 输出中的本地文件 / 目录 / 代码位置引用进行标准化与重渲染,提升在 IM 平台中的可读性。 + +这是一个 **opt-in** 功能: + +- 未配置 `[projects.references]` 时,现有行为保持不变 +- 只有命中 `normalize_agents` 和 `render_platforms` 时,才会启用 + +### 推荐配置 + +```toml +[projects.references] +normalize_agents = ["all"] +render_platforms = ["all"] +display_path = "relative" +marker_style = "emoji" +enclosure_style = "code" +``` + +### 字段说明 + +- `normalize_agents` + - 控制哪些 Agent 输出参与这套引用处理 + - 当前初始支持:`codex`、`claudecode`、`all` + +- `render_platforms` + - 控制在哪些平台发送前应用展示重写 + - 当前初始支持:`feishu`、`weixin`、`all` + +- `display_path` + - 控制路径主体的显示层级 + - 可选值:`absolute`、`relative`、`basename`、`dirname_basename`、`smart` + +- `marker_style` + - 控制前缀标记样式 + - 可选值:`none`、`ascii`、`emoji` + +- `enclosure_style` + - 控制路径主体的包裹样式 + - 可选值:`none`、`bracket`、`angle`、`fullwidth`、`code` + +### 支持的引用输入 + +当前初始支持识别这些常见形式: + +- 绝对路径 +- 相对路径 +- 文件 / 目录引用 +- `path:line` +- `path:line:col` +- `path:start-end` +- `path#L42` +- Markdown 本地文件链接 +- Claude 风格的反引号绝对路径引用 + +### 行为说明 + +- 只处理 Agent 输出: + - thinking + - final response + - stream preview + - progress / card 中的 Agent 文本 + +- 不处理: + - 系统消息 + - `/workspace`、`/dir`、`/status` 等命令回复 + - raw tool result + +- 网页链接会保持原样,不会被本地引用重写逻辑污染 + +### 推荐默认值说明 + +当前最推荐的组合是: + +- `display_path = "relative"` +- `marker_style = "emoji"` +- `enclosure_style = "code"` + +这样通常会得到类似: + +- `📄 ui/recovery_contact_form.tsx:11` +- `📁 docs/spec.v1/` + +如果不希望使用 emoji,更推荐: + +- `display_path = "dirname_basename"` +- `marker_style = "ascii"` +- `enclosure_style = "code"` + +--- + +## 引用查看(`/show`) + +可直接基于一个文件 / 目录 / 代码位置引用查看内容,而不必手写 `/shell sed ...`。 + +### 聊天命令 + +```text +/show <路径> 查看文件前 80 行 +/show <路径:行号> 查看该行附近上下文 +/show <路径:起止行> 查看指定 range +/show <目录路径/> 查看一级目录列表 +``` + +支持的输入形式包括: + +- 绝对路径 +- 相对路径(相对当前 Agent 工作目录) +- `path:line` +- `path:line:col` +- `path:start-end` +- `path#L42` +- Markdown 本地文件链接,如: + - `[file.ts](/abs/path/file.ts#L42)` + +### 行为说明 + +- 文件,无位置: + - 默认显示文件前 80 行 +- `path:line` / `path#L42`: + - 默认显示该位置附近上下文 +- `path:start-end`: + - 默认显示该 range +- 目录: + - 默认显示一级目录内容 + +说明: + +- `/show` 只解析“纯引用文本”,不解析前端展示层包装后的 `📄 ...` / `[FILE] ...` 这类样式 +- `/show` 属于本地文件系统查看命令,与 `/shell`、`/dir` 类似,默认受 `admin_from` 权限控制 + +示例: + +```text +/show ui/recovery_contact_form.tsx +/show svc/recovery_session_reconciler.go:12 +/show svc/recovery_session_reconciler_test.go:8-17 +/show docs/spec.v1/ +``` + +--- + ## 飞书配置 CLI 可以直接通过 CLI 完成飞书/Lark 机器人创建或关联,并自动写回 `config.toml`: