1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
//! Tool context hint extraction for channel display.
//!
//! Extracts a short, meaningful description from a tool's input parameters
//! so channels can show what each tool call is doing (e.g. "grep (`pattern`)").
use super::sanitize::redact_tool_input;
use super::string::{tilde_home, truncate_middle};
/// Extract a short, meaningful context hint from a tool's input for channel display.
/// Runs the input through the secret sanitizer first so no API keys or tokens
/// can leak into the streaming indicator via command or url fields.
/// Returns a formatted string like ` ("hint")` or empty string if no hint found.
pub fn tool_context_hint(name: &str, input: &serde_json::Value) -> String {
let safe = redact_tool_input(input);
let hint: Option<String> = match name {
"bash" => safe
.get("command")
.and_then(|v| v.as_str())
.map(String::from),
"read" | "read_file" | "write" | "write_file" | "edit" | "edit_file" => safe
.get("path")
.or_else(|| safe.get("file_path"))
.and_then(|v| v.as_str())
.map(String::from),
"glob" => safe
.get("pattern")
.and_then(|v| v.as_str())
.map(String::from),
"grep" => safe
.get("pattern")
.and_then(|v| v.as_str())
.map(String::from),
"ls" => safe.get("path").and_then(|v| v.as_str()).map(String::from),
"http_request" | "web_fetch" => safe.get("url").and_then(|v| v.as_str()).map(String::from),
"brave_search" | "exa_search" | "web_search" | "memory_search" | "session_search" => {
safe.get("query").and_then(|v| v.as_str()).map(String::from)
}
"telegram_send" | "discord_send" | "slack_send" | "trello_send" => safe
.get("action")
.and_then(|v| v.as_str())
.map(String::from),
"agent" | "Agent" => safe
.get("description")
.and_then(|v| v.as_str())
.map(String::from),
"cron_manage" => {
let action = safe.get("action").and_then(|v| v.as_str()).unwrap_or("?");
let name = safe.get("name").and_then(|v| v.as_str());
match (action, name) {
("list", _) => Some("list jobs".to_string()),
(act, Some(n)) => Some(format!("{} '{}'", act, n)),
(act, None) => Some(act.to_string()),
}
}
"plan" => {
let op = safe
.get("operation")
.and_then(|v| v.as_str())
.unwrap_or("?");
let title = safe
.get("title")
.or_else(|| safe.get("name"))
.and_then(|v| v.as_str());
match title {
Some(t) => Some(format!("{}: {}", op, t)),
None => Some(op.to_string()),
}
}
"task_manager" => {
let op = safe
.get("operation")
.and_then(|v| v.as_str())
.unwrap_or("?");
let title = safe.get("title").and_then(|v| v.as_str());
match title {
Some(t) => Some(format!("{}: {}", op, t)),
None => Some(op.to_string()),
}
}
"lsp" => safe
.get("operation")
.and_then(|v| v.as_str())
.map(String::from),
// Fallback: build "action: detail" from common field patterns
_ => safe.as_object().and_then(|m| {
let action = m
.get("action")
.or_else(|| m.get("operation"))
.and_then(|v| v.as_str());
let detail_keys = [
"name",
"prompt",
"query",
"path",
"file_path",
"pattern",
"description",
"title",
"url",
"command",
"id",
"job_id",
];
let detail = detail_keys
.iter()
.find_map(|k| m.get(*k).and_then(|v| v.as_str()));
match (action, detail) {
(Some(act), Some(det)) => Some(format!("{}: {}", act, det)),
(None, Some(det)) => Some(det.to_string()),
(Some(act), None) => {
// Action-only — find any other string field
let other = m
.iter()
.find(|(k, v)| *k != "action" && *k != "operation" && v.is_string())
.and_then(|(_, v)| v.as_str());
match other {
Some(o) => Some(format!("{}: {}", act, o)),
None => Some(act.to_string()),
}
}
(None, None) => m.values().find_map(|v| match v {
serde_json::Value::String(s) if !s.is_empty() => Some(s.clone()),
serde_json::Value::Number(n) => Some(n.to_string()),
_ => None,
}),
}
}),
};
match hint {
Some(h) if !h.is_empty() => {
// Collapse $HOME → ~ first so the truncation budget is spent on
// meaningful suffix instead of the user's home prefix. Then
// middle-truncate so filenames / final args survive (much more
// informative than the leading directory components that the
// old tail-truncation kept).
let tilde = tilde_home(&h);
let truncated = truncate_middle(&tilde, 80);
format!(" (`{truncated}`)")
}
_ => String::new(),
}
}
/// Raw, untruncated status source for a tool call: the redacted bash `command`,
/// verbatim. This is the input the flow's `#`-comment status extractor reads
/// (#482/#488) — it must NOT be decorated or truncated like `tool_context_hint`.
/// The display hint wraps the command as `` (`…`) `` and middle-truncates to 80
/// bytes, which drops a first-line `#` comment (the wrapper prefix hides the
/// leading `#`) and can elide comments mid-command; feeding that to the
/// extractor was the #488 bug. Empty for non-bash tools (the extractor only
/// runs on bash lines).
pub fn tool_status_source(name: &str, input: &serde_json::Value) -> String {
if name != "bash" {
return String::new();
}
redact_tool_input(input)
.get("command")
.and_then(|v| v.as_str())
.map(String::from)
.unwrap_or_default()
}