Feature: normalize local references for IM rendering and add /show for direct file inspection (#495)

* feat(references): normalize and render local agent references

* feat(show): add reference-aware file and directory viewing

* docs: document references config and show usage

* chore: remove local working docs from PR branch

* fix: address lint issues in reference pipeline

* fix: drop stale quiet leftovers after main rebase

* fix: clean remaining rebase markers in show tests
This commit is contained in:
Jerry
2026-04-09 16:50:25 +08:00
committed by GitHub
parent f70b98f6d5
commit bfb6d69780
17 changed files with 2384 additions and 64 deletions

View File

@@ -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()

View File

@@ -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 / 开启机器人能力

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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())
}
// ──────────────────────────────────────────────────────────────

View File

@@ -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) {

View File

@@ -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 <command>\n Run a shell command and return the output\n\n" +
"/show <ref>\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 <command> — Run a shell command\n" +
"/show <ref> — 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 <path|path:line|path:start-end|dir/>`\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 <ruta|ruta:línea|ruta:inicio-fin|dir/>`\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: {

View File

@@ -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

View File

@@ -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)
}
}

196
core/reference_parse.go Normal file
View File

@@ -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
}

543
core/reference_render.go Normal file
View File

@@ -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
}
}

View File

@@ -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)
}
}

314
core/reference_show.go Normal file
View File

@@ -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
}

130
core/reference_show_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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 <alias>]` | 列出可用模型或按别名切换 |
| `/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`