mirror of
https://github.com/chenhg5/cc-connect.git
synced 2026-07-03 12:28:10 +08:00
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:
@@ -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()
|
||||
|
||||
@@ -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 / 开启机器人能力
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
225
core/engine.go
225
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())
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -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) {
|
||||
|
||||
52
core/i18n.go
52
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 <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: {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
196
core/reference_parse.go
Normal 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
543
core/reference_render.go
Normal 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
|
||||
}
|
||||
}
|
||||
218
core/reference_render_test.go
Normal file
218
core/reference_render_test.go
Normal 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
314
core/reference_show.go
Normal 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
130
core/reference_show_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`:
|
||||
|
||||
Reference in New Issue
Block a user