返回 DeepSeek-Reasonix
toolcard.go
根目录 / internal / cli / toolcard.go
1 // Formats a tool call as a Claude-style card line: a "● Verb(primary arg)"
2 // header instead of the raw "-> name {json}", plus the "⎿" continuation gutter.
3 package cli
4
5 import (
6 "encoding/json"
7 "fmt"
8 "strconv"
9 "strings"
10
11 "reasonix/internal/event"
12 "reasonix/internal/shellrun"
13 "reasonix/internal/tool"
14 )
15
16 // connector is the Claude-style "⎿" gutter that ties a continuation block (tool
17 // output, streamed thinking) to the header line above it.
18 const connector = " ⎿ "
19
20 // connectorBlock renders lines under the connector: the first carries the "⎿"
21 // gutter, the rest align beneath it. Returns "" for no lines.
22 func connectorBlock(lines []string) string {
23 return renderConnectorBlock(lines, false)
24 }
25
26 // connectorBlockCopy mirrors connectorBlock and marks only its generated
27 // prefixes as omitted copy spans. The caller stores this rendition beside the
28 // visible fixed block; it never reaches the terminal.
29 func connectorBlockCopy(lines []string) string {
30 return renderConnectorBlock(lines, true)
31 }
32
33 func renderConnectorBlock(lines []string, copyMode bool) string {
34 if len(lines) == 0 {
35 return ""
36 }
37 indent := strings.Repeat(" ", len([]rune(connector)))
38 firstPrefix := dim(connector)
39 nextPrefix := indent
40 if copyMode {
41 firstPrefix = copyOmitSpan(firstPrefix)
42 nextPrefix = copyOmitSpan(nextPrefix)
43 }
44 var out strings.Builder
45 out.WriteString(firstPrefix + lines[0])
46 for _, ln := range lines[1:] {
47 out.WriteString("\n" + nextPrefix + ln)
48 }
49 return out.String()
50 }
51
52 // toolVerb maps a tool's snake_case id to the verb shown in its card.
53 var toolVerb = map[string]string{
54 "bash": "Bash",
55 "pwsh": "PowerShell",
56 "bash_output": "Output",
57 "job_output": "Output",
58 "kill_shell": "Kill",
59 "job_kill": "Kill",
60 "wait": "Wait",
61 "read_file": "Read",
62 "write_file": "Write",
63 "edit_file": "Update",
64 "multi_edit": "Update",
65 "move_file": "Move",
66 "delete_range": "Update",
67 "delete_symbol": "Update",
68 "notebook_edit": "Update",
69 "glob": "Glob",
70 "grep": "Search",
71 "ls": "List",
72 "web_fetch": "Fetch",
73 "web_search": "Search",
74 "complete_step": "Step",
75 "task": "Task",
76 "use_capability": "MCP",
77 }
78
79 // toolArgKey is the JSON field shown in parentheses for each tool (wait is
80 // special-cased — it carries a job_ids array, not a scalar).
81 var toolArgKey = map[string]string{
82 "bash": "command",
83 "pwsh": "description",
84 "bash_output": "job_id",
85 "job_output": "job_id",
86 "kill_shell": "job_id",
87 "job_kill": "job_id",
88 "read_file": "path",
89 "write_file": "path",
90 "edit_file": "path",
91 "multi_edit": "path",
92 "move_file": "source_path",
93 "delete_range": "path",
94 "delete_symbol": "name",
95 "notebook_edit": "path",
96 "glob": "pattern",
97 "grep": "pattern",
98 "ls": "path",
99 "web_fetch": "url",
100 "web_search": "query",
101 "complete_step": "summary",
102 "task": "description",
103 }
104
105 // toolDot returns the "●" status glyph coloured by the tool's category so the eye
106 // can tell reads (cyan) from writes (green), shell (yellow), process control
107 // (magenta), and everything else (copper) at a glance.
108 func toolDot(name string) string {
109 var c cliColor
110 switch toolCategory[name] {
111 case "read":
112 c = activeCLITheme.toolRead
113 case "write":
114 c = activeCLITheme.success
115 case "exec":
116 c = activeCLITheme.warn
117 case "proc":
118 c = activeCLITheme.toolProc
119 default:
120 c = activeCLITheme.accent
121 }
122 return themeFg(c, "●")
123 }
124
125 var toolCategory = map[string]string{
126 "read_file": "read", "ls": "read", "glob": "read", "grep": "read",
127 "web_fetch": "read", "web_search": "read", "bash_output": "read", "job_output": "read",
128 "write_file": "write", "edit_file": "write", "multi_edit": "write",
129 "move_file": "write", "delete_range": "write", "delete_symbol": "write", "notebook_edit": "write",
130 "bash": "exec", "pwsh": "exec", "powershell": "exec",
131 "wait": "proc", "kill_shell": "proc", "job_kill": "proc",
132 }
133
134 // toolDisplayName returns the card verb for a tool: a mapped builtin verb, the
135 // short name for an MCP tool (mcp__server__tool), or the raw id as a fallback.
136 func toolDisplayName(name string) string {
137 if _, short, ok := tool.SplitMCPName(name); ok {
138 return short
139 }
140 if v, ok := toolVerb[name]; ok {
141 return v
142 }
143 return name
144 }
145
146 // shellToolDisplayName prefers the actual interpreter label when structured
147 // execution metadata is present (Git Bash / Windows PowerShell / PowerShell 7+).
148 func shellToolDisplayName(name string, ex *event.ShellExecution) string {
149 if tool.IsShellToolName(name) && ex != nil && ex.Shell != "" {
150 return shellrun.DisplayName(&tool.ShellExecution{Shell: ex.Shell, ShellVersion: ex.ShellVersion})
151 }
152 return toolDisplayName(name)
153 }
154
155 // shellFailureDetail appends exit code, failure phase, and mutation-risk hints
156 // for failed shell results. Empty when there is no structured metadata.
157 func shellFailureDetail(ex *event.ShellExecution) string {
158 if ex == nil {
159 return ""
160 }
161 var parts []string
162 if ex.ExitCode != nil {
163 parts = append(parts, fmt.Sprintf("exit %d", *ex.ExitCode))
164 }
165 if ex.FailurePhase != "" {
166 parts = append(parts, ex.FailurePhase)
167 }
168 switch ex.FailurePhase {
169 case tool.ShellPhasePreflight, tool.ShellPhaseAuthorization, tool.ShellPhaseDependency, tool.ShellPhaseLaunch:
170 parts = append(parts, "not executed")
171 default:
172 if ex.MutationRisk == tool.ShellMutationMayBePartial {
173 parts = append(parts, "may be partial")
174 }
175 }
176 return strings.Join(parts, " · ")
177 }
178
179 // toolArg pulls the primary argument shown in the card's parentheses.
180 func toolArg(name, args string) string {
181 var m map[string]any
182 if json.Unmarshal([]byte(args), &m) != nil {
183 return ""
184 }
185 if name == "wait" {
186 return argList(m["job_ids"])
187 }
188 if name == "use_capability" {
189 if id, ok := m["capability_id"].(string); ok && strings.TrimSpace(id) != "" {
190 return strings.TrimSpace(id)
191 }
192 if action, ok := m["action"].(string); ok {
193 return strings.TrimSpace(action)
194 }
195 return ""
196 }
197 v, ok := m[toolArgKey[name]]
198 if !ok {
199 return ""
200 }
201 switch x := v.(type) {
202 case string:
203 return strings.TrimSpace(x)
204 case []any:
205 return argList(x)
206 case float64:
207 return strconv.Itoa(int(x))
208 default:
209 return ""
210 }
211 }
212
213 func argList(v any) string {
214 arr, ok := v.([]any)
215 if !ok {
216 return ""
217 }
218 parts := make([]string, 0, len(arr))
219 for _, e := range arr {
220 if s, ok := e.(string); ok {
221 parts = append(parts, s)
222 }
223 }
224 return strings.Join(parts, ", ")
225 }
226
227 // toolCard renders the dispatch line: " ⏺ Verb(arg)", arg clamped to width.
228 func toolCard(name, args string, width int) string {
229 return " " + toolDot(name) + " " + toolHead(name, toolArg(name, args), width)
230 }
231
232 // toolHead builds "Verb(arg)" with the verb bold and the arg clamped to fit the
233 // remaining width; shared by toolCard and the diff block header.
234 func toolHead(name, arg string, width int) string {
235 label := toolDisplayName(name)
236 head := bold(label)
237 if arg != "" {
238 avail := width - 4 - len([]rune(label)) - 2
239 head += dim("(") + clampPlain(arg, avail) + dim(")")
240 }
241 return head
242 }
243
243 lines GO