Improve tool detail formatting
This commit is contained in:
@@ -1130,7 +1130,7 @@ func (b *Bot) handleApprovalCallback(ctx context.Context, callback *CallbackQuer
|
|||||||
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Details sent."); err != nil {
|
if err := b.tg.AnswerCallbackQuery(ctx, callback.ID, "Details sent."); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return b.sendLong(ctx, callback.Message.Chat.ID, approval.PayloadJSON)
|
return b.sendHTML(ctx, callback.Message.Chat.ID, renderApprovalHTML(approval.Kind, json.RawMessage(approval.PayloadJSON), ""))
|
||||||
}
|
}
|
||||||
if approval.Status != "pending" {
|
if approval.Status != "pending" {
|
||||||
return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Already resolved.")
|
return b.tg.AnswerCallbackQuery(ctx, callback.ID, "Already resolved.")
|
||||||
@@ -1194,7 +1194,7 @@ func parseCodexThreadItem(raw json.RawMessage) (codexThreadItemView, error) {
|
|||||||
func renderCodexItemStarted(item codexThreadItemView) string {
|
func renderCodexItemStarted(item codexThreadItemView) string {
|
||||||
switch item.Type {
|
switch item.Type {
|
||||||
case "commandExecution":
|
case "commandExecution":
|
||||||
return SummaryDetailsHTMLLimited(joinNonEmpty("Tool call: command started", commandSummaryLine(item.Command)), commandStartedDetails(item), TelegramHTMLMessageLimit)
|
return SummaryDetailsRawHTMLLimited(joinNonEmpty("Tool call: command started", commandSummaryLine(item.Command)), commandStartedDetailsHTML(item), TelegramHTMLMessageLimit)
|
||||||
case "fileChange":
|
case "fileChange":
|
||||||
return "Tool call: file change started"
|
return "Tool call: file change started"
|
||||||
case "mcpToolCall":
|
case "mcpToolCall":
|
||||||
@@ -1221,17 +1221,19 @@ func renderCodexItemCompleted(item codexThreadItemView) string {
|
|||||||
if item.ExitCode != nil {
|
if item.ExitCode != nil {
|
||||||
status = fmt.Sprintf("Exit code: %d", *item.ExitCode)
|
status = fmt.Sprintf("Exit code: %d", *item.ExitCode)
|
||||||
}
|
}
|
||||||
return SummaryDetailsHTMLLimited(joinNonEmpty("Tool call: command finished", commandSummaryLine(item.Command), status), renderCodexItemDetails(item), TelegramHTMLMessageLimit)
|
return SummaryDetailsRawHTMLLimited(joinNonEmpty("Tool call: command finished", commandSummaryLine(item.Command), status), renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit)
|
||||||
case "fileChange":
|
case "fileChange":
|
||||||
return joinNonEmpty("Tool call: file change finished", fmt.Sprintf("Changed files: %d", len(item.Changes)), "Status: "+item.Status)
|
return joinNonEmpty("Tool call: file change finished", fmt.Sprintf("Changed files: %d", len(item.Changes)), "Status: "+item.Status)
|
||||||
case "mcpToolCall":
|
case "mcpToolCall":
|
||||||
return joinNonEmpty("Tool call: MCP finished", "Tool: "+toolDisplayName(item.Server, item.Tool), "Status: "+item.Status)
|
summary := joinNonEmpty("Tool call: MCP finished", "Tool: "+toolDisplayName(item.Server, item.Tool), "Status: "+item.Status)
|
||||||
|
return SummaryDetailsRawHTMLLimited(summary, renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit)
|
||||||
case "dynamicToolCall":
|
case "dynamicToolCall":
|
||||||
status := item.Status
|
status := item.Status
|
||||||
if item.Success != nil {
|
if item.Success != nil {
|
||||||
status = fmt.Sprintf("success=%t", *item.Success)
|
status = fmt.Sprintf("success=%t", *item.Success)
|
||||||
}
|
}
|
||||||
return joinNonEmpty("Tool call: finished", "Tool: "+toolDisplayName(item.Namespace, item.Tool), "Status: "+status)
|
summary := joinNonEmpty("Tool call: finished", "Tool: "+toolDisplayName(item.Namespace, item.Tool), "Status: "+status)
|
||||||
|
return SummaryDetailsRawHTMLLimited(summary, renderCodexItemDetailsHTML(item), TelegramHTMLMessageLimit)
|
||||||
case "webSearch":
|
case "webSearch":
|
||||||
return joinNonEmpty("Tool call: web search finished", "Query: "+item.Query)
|
return joinNonEmpty("Tool call: web search finished", "Query: "+item.Query)
|
||||||
case "imageView":
|
case "imageView":
|
||||||
@@ -1257,66 +1259,245 @@ func commandSummaryLine(command string) string {
|
|||||||
return "Command: " + string(runes[:commandSummaryLimit]) + "..."
|
return "Command: " + string(runes[:commandSummaryLimit]) + "..."
|
||||||
}
|
}
|
||||||
|
|
||||||
func commandStartedDetails(item codexThreadItemView) string {
|
func commandStartedDetailsHTML(item codexThreadItemView) string {
|
||||||
var lines []string
|
var parts []string
|
||||||
if strings.TrimSpace(item.Command) != "" && len([]rune(strings.TrimSpace(item.Command))) > commandSummaryLimit {
|
if command := strings.TrimSpace(item.Command); command != "" && len([]rune(command)) > commandSummaryLimit {
|
||||||
lines = append(lines, "command: "+strings.TrimSpace(item.Command))
|
parts = append(parts, "<b>Command</b>", CodeBlockHTML("bash", command))
|
||||||
}
|
}
|
||||||
if strings.TrimSpace(item.CWD) != "" {
|
if cwd := strings.TrimSpace(item.CWD); cwd != "" {
|
||||||
lines = append(lines, "cwd: "+strings.TrimSpace(item.CWD))
|
parts = append(parts, FieldHTML("CWD", cwd))
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return strings.Join(parts, "\n")
|
||||||
}
|
}
|
||||||
|
|
||||||
func renderCodexItemDetails(item codexThreadItemView) string {
|
func renderCodexItemDetailsHTML(item codexThreadItemView) string {
|
||||||
var lines []string
|
var parts []string
|
||||||
appendKV := func(key string, value any) {
|
appendField := func(label, value string) {
|
||||||
switch v := value.(type) {
|
if html := FieldHTML(label, value); html != "" {
|
||||||
case string:
|
parts = append(parts, html)
|
||||||
if strings.TrimSpace(v) != "" {
|
|
||||||
lines = append(lines, fmt.Sprintf("%s: %s", key, v))
|
|
||||||
}
|
|
||||||
case *int:
|
|
||||||
if v != nil {
|
|
||||||
lines = append(lines, fmt.Sprintf("%s: %d", key, *v))
|
|
||||||
}
|
|
||||||
case *int64:
|
|
||||||
if v != nil {
|
|
||||||
lines = append(lines, fmt.Sprintf("%s: %d", key, *v))
|
|
||||||
}
|
|
||||||
case *bool:
|
|
||||||
if v != nil {
|
|
||||||
lines = append(lines, fmt.Sprintf("%s: %t", key, *v))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
appendKV("type", item.Type)
|
appendInt := func(label string, value *int) {
|
||||||
appendKV("id", item.ID)
|
if value != nil {
|
||||||
appendKV("command", item.Command)
|
appendField(label, strconv.Itoa(*value))
|
||||||
appendKV("cwd", item.CWD)
|
}
|
||||||
appendKV("status", item.Status)
|
|
||||||
appendKV("tool", toolDisplayName(item.Namespace, item.Tool))
|
|
||||||
appendKV("server", item.Server)
|
|
||||||
appendKV("query", item.Query)
|
|
||||||
appendKV("path", item.Path)
|
|
||||||
appendKV("savedPath", item.SavedPath)
|
|
||||||
appendKV("exitCode", item.ExitCode)
|
|
||||||
appendKV("durationMs", item.DurationMs)
|
|
||||||
appendKV("success", item.Success)
|
|
||||||
if len(item.Arguments) > 0 && string(item.Arguments) != "null" {
|
|
||||||
lines = append(lines, "arguments: "+string(item.Arguments))
|
|
||||||
}
|
}
|
||||||
if len(item.Changes) > 0 {
|
appendInt64 := func(label string, value *int64) {
|
||||||
|
if value != nil {
|
||||||
|
appendField(label, strconv.FormatInt(*value, 10))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
appendBool := func(label string, value *bool) {
|
||||||
|
if value != nil {
|
||||||
|
appendField(label, strconv.FormatBool(*value))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item.Type {
|
||||||
|
case "commandExecution":
|
||||||
|
appendField("CWD", item.CWD)
|
||||||
|
if command := strings.TrimSpace(item.Command); command != "" && len([]rune(command)) > commandSummaryLimit {
|
||||||
|
parts = append(parts, "<b>Command</b>", CodeBlockHTML("bash", command))
|
||||||
|
}
|
||||||
|
appendInt("Exit code", item.ExitCode)
|
||||||
|
appendInt64("Duration ms", item.DurationMs)
|
||||||
|
if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" {
|
||||||
|
parts = append(parts, "<b>Output</b>", CodeBlockHTML("text", *item.AggregatedOutput))
|
||||||
|
}
|
||||||
|
case "fileChange":
|
||||||
|
appendField("Status", item.Status)
|
||||||
for _, change := range item.Changes {
|
for _, change := range item.Changes {
|
||||||
if change.Path != "" {
|
appendField("Changed", change.Path)
|
||||||
lines = append(lines, "changed: "+change.Path)
|
}
|
||||||
}
|
case "mcpToolCall":
|
||||||
|
appendField("Tool", toolDisplayName(item.Server, item.Tool))
|
||||||
|
appendField("Status", item.Status)
|
||||||
|
parts = append(parts, renderArgumentsDetailsHTML(item.Arguments)...)
|
||||||
|
case "dynamicToolCall":
|
||||||
|
appendField("Tool", toolDisplayName(item.Namespace, item.Tool))
|
||||||
|
appendField("Status", item.Status)
|
||||||
|
appendBool("Success", item.Success)
|
||||||
|
parts = append(parts, renderArgumentsDetailsHTML(item.Arguments)...)
|
||||||
|
case "webSearch":
|
||||||
|
appendField("Query", item.Query)
|
||||||
|
case "imageView":
|
||||||
|
appendField("Path", item.Path)
|
||||||
|
case "imageGeneration":
|
||||||
|
appendField("Status", item.Status)
|
||||||
|
appendField("Saved path", item.SavedPath)
|
||||||
|
case "collabAgentToolCall":
|
||||||
|
appendField("Tool", item.Tool)
|
||||||
|
appendField("Status", item.Status)
|
||||||
|
default:
|
||||||
|
appendField("Type", item.Type)
|
||||||
|
appendField("Status", item.Status)
|
||||||
|
parts = append(parts, renderArgumentsDetailsHTML(item.Arguments)...)
|
||||||
|
}
|
||||||
|
return strings.Join(nonEmptyHTML(parts), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderArgumentsDetailsHTML(raw json.RawMessage) []string {
|
||||||
|
if len(raw) == 0 || string(raw) == "null" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var value any
|
||||||
|
if err := json.Unmarshal(raw, &value); err != nil {
|
||||||
|
return []string{"<b>Arguments</b>", CodeBlockHTML("json", string(raw))}
|
||||||
|
}
|
||||||
|
object, ok := value.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
return []string{"<b>Arguments</b>", CodeBlockHTML("json", compactPrettyJSON(value))}
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts []string
|
||||||
|
for _, key := range preferredArgumentKeys(object) {
|
||||||
|
part := renderArgumentFieldHTML(key, object[key])
|
||||||
|
if strings.TrimSpace(part) != "" {
|
||||||
|
parts = append(parts, part)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if item.AggregatedOutput != nil && strings.TrimSpace(*item.AggregatedOutput) != "" {
|
if len(parts) == 0 && len(object) > 0 {
|
||||||
lines = append(lines, "output:\n"+strings.TrimSpace(*item.AggregatedOutput))
|
parts = append(parts, "<b>Arguments</b>", CodeBlockHTML("json", compactPrettyJSON(object)))
|
||||||
}
|
}
|
||||||
return strings.Join(lines, "\n")
|
return parts
|
||||||
|
}
|
||||||
|
|
||||||
|
func preferredArgumentKeys(object map[string]any) []string {
|
||||||
|
preferred := []string{"cmd", "command", "script", "code", "content", "input", "query", "path", "file", "filename", "cwd", "args", "config", "patch"}
|
||||||
|
seen := make(map[string]bool, len(object))
|
||||||
|
var keys []string
|
||||||
|
for _, key := range preferred {
|
||||||
|
if _, ok := object[key]; ok {
|
||||||
|
keys = append(keys, key)
|
||||||
|
seen[key] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for key := range object {
|
||||||
|
if !seen[key] && isMeaningfulArgumentKey(key) {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return keys
|
||||||
|
}
|
||||||
|
|
||||||
|
func isMeaningfulArgumentKey(key string) bool {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
for _, part := range []string{"command", "cmd", "code", "content", "path", "file", "query", "input", "config", "patch", "cwd"} {
|
||||||
|
if strings.Contains(key, part) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func renderArgumentFieldHTML(key string, value any) string {
|
||||||
|
label := argumentLabel(key)
|
||||||
|
text, complex := argumentValueText(value)
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if complex || shouldUseCodeBlock(key, text) {
|
||||||
|
return "<b>" + EscapeHTML(label) + "</b>\n" + CodeBlockHTML(languageForArgument(key, text), text)
|
||||||
|
}
|
||||||
|
return FieldHTML(label, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func argumentLabel(key string) string {
|
||||||
|
key = strings.TrimSpace(key)
|
||||||
|
if key == "" {
|
||||||
|
return "Argument"
|
||||||
|
}
|
||||||
|
switch strings.ToLower(key) {
|
||||||
|
case "cwd":
|
||||||
|
return "CWD"
|
||||||
|
case "cmd":
|
||||||
|
return "cmd"
|
||||||
|
}
|
||||||
|
label := strings.ReplaceAll(key, "_", " ")
|
||||||
|
return strings.ToUpper(label[:1]) + label[1:]
|
||||||
|
}
|
||||||
|
|
||||||
|
func argumentValueText(value any) (string, bool) {
|
||||||
|
switch v := value.(type) {
|
||||||
|
case string:
|
||||||
|
return v, false
|
||||||
|
case float64:
|
||||||
|
return strconv.FormatFloat(v, 'f', -1, 64), false
|
||||||
|
case bool:
|
||||||
|
return strconv.FormatBool(v), false
|
||||||
|
case nil:
|
||||||
|
return "", false
|
||||||
|
default:
|
||||||
|
return compactPrettyJSON(v), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func shouldUseCodeBlock(key, text string) bool {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
if strings.Contains(text, "\n") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, marker := range []string{"command", "cmd", "script", "code", "content", "config", "patch"} {
|
||||||
|
if strings.Contains(key, marker) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func languageForArgument(key, text string) string {
|
||||||
|
key = strings.ToLower(key)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(key, "command") || strings.Contains(key, "cmd") || strings.Contains(key, "script"):
|
||||||
|
return "bash"
|
||||||
|
case strings.Contains(key, "config") || looksLikeJSON(text):
|
||||||
|
return "json"
|
||||||
|
case strings.Contains(key, "patch"):
|
||||||
|
return "diff"
|
||||||
|
case strings.Contains(key, "code") || strings.Contains(key, "content"):
|
||||||
|
return languageForContent(text)
|
||||||
|
default:
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func languageForContent(text string) string {
|
||||||
|
trimmed := strings.TrimSpace(text)
|
||||||
|
switch {
|
||||||
|
case looksLikeJSON(trimmed):
|
||||||
|
return "json"
|
||||||
|
case strings.HasPrefix(trimmed, "package ") || strings.Contains(trimmed, "func "):
|
||||||
|
return "go"
|
||||||
|
case strings.Contains(trimmed, "#!/bin/sh") || strings.Contains(trimmed, "#!/usr/bin/env bash"):
|
||||||
|
return "bash"
|
||||||
|
case strings.Contains(trimmed, "apiVersion:") || strings.Contains(trimmed, "kind:"):
|
||||||
|
return "yaml"
|
||||||
|
default:
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func looksLikeJSON(text string) bool {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
return (strings.HasPrefix(text, "{") && strings.HasSuffix(text, "}")) || (strings.HasPrefix(text, "[") && strings.HasSuffix(text, "]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func compactPrettyJSON(value any) string {
|
||||||
|
data, err := json.MarshalIndent(value, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprint(value)
|
||||||
|
}
|
||||||
|
return string(data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonEmptyHTML(parts []string) []string {
|
||||||
|
out := make([]string, 0, len(parts))
|
||||||
|
for _, part := range parts {
|
||||||
|
if strings.TrimSpace(part) != "" {
|
||||||
|
out = append(out, part)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func joinNonEmpty(lines ...string) string {
|
func joinNonEmpty(lines ...string) string {
|
||||||
@@ -2543,23 +2724,57 @@ func renderApprovalHTML(kind string, raw json.RawMessage, status string) string
|
|||||||
}
|
}
|
||||||
for _, key := range []string{"command", "cwd", "grantRoot", "permissions"} {
|
for _, key := range []string{"command", "cwd", "grantRoot", "permissions"} {
|
||||||
if value, ok := params[key]; ok {
|
if value, ok := params[key]; ok {
|
||||||
lines = append(lines, fmt.Sprintf("%s: %s", key, conciseValue(value)))
|
lines = append(lines, fmt.Sprintf("%s: %s", argumentLabel(key), conciseValue(value)))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
summary := strings.Join(lines, "\n")
|
summary := strings.Join(lines, "\n")
|
||||||
details := prettyJSON(raw)
|
details := renderApprovalDetailsHTML(kind, raw)
|
||||||
limit := TelegramHTMLMessageLimit
|
limit := TelegramHTMLMessageLimit
|
||||||
if status != "" {
|
if status != "" {
|
||||||
limit -= len([]rune(status)) + 1
|
limit -= len([]rune(status)) + 1
|
||||||
}
|
}
|
||||||
text := SummaryDetailsHTMLLimited(summary, details, limit)
|
text := SummaryDetailsRawHTMLLimited(summary, details, limit)
|
||||||
if status != "" {
|
if status != "" {
|
||||||
text += "\n" + EscapeHTML(status)
|
text += "\n" + EscapeHTML(status)
|
||||||
}
|
}
|
||||||
return text
|
return text
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func renderApprovalDetailsHTML(kind string, raw json.RawMessage) string {
|
||||||
|
var params map[string]any
|
||||||
|
if err := json.Unmarshal(raw, ¶ms); err != nil {
|
||||||
|
return CodeBlockHTML("json", string(raw))
|
||||||
|
}
|
||||||
|
var parts []string
|
||||||
|
appendValue := func(label string, value any) {
|
||||||
|
text, complex := argumentValueText(value)
|
||||||
|
if strings.TrimSpace(text) == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if complex || strings.Contains(text, "\n") || strings.EqualFold(label, "Command") || strings.EqualFold(label, "Permissions") {
|
||||||
|
language := "text"
|
||||||
|
if strings.EqualFold(label, "Command") {
|
||||||
|
language = "bash"
|
||||||
|
} else if complex || strings.EqualFold(label, "Permissions") || looksLikeJSON(text) {
|
||||||
|
language = "json"
|
||||||
|
}
|
||||||
|
parts = append(parts, "<b>"+EscapeHTML(label)+"</b>", CodeBlockHTML(language, text))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
parts = append(parts, FieldHTML(label, text))
|
||||||
|
}
|
||||||
|
for _, key := range []string{"command", "cwd", "grantRoot", "permissions", "reason"} {
|
||||||
|
if value, ok := params[key]; ok {
|
||||||
|
appendValue(argumentLabel(key), value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(parts) == 0 {
|
||||||
|
return CodeBlockHTML("json", prettyJSON(raw))
|
||||||
|
}
|
||||||
|
return strings.Join(nonEmptyHTML(parts), "\n")
|
||||||
|
}
|
||||||
|
|
||||||
func approvalStatusLine(decision string) string {
|
func approvalStatusLine(decision string) string {
|
||||||
switch decision {
|
switch decision {
|
||||||
case "accept":
|
case "accept":
|
||||||
@@ -2577,7 +2792,9 @@ func approvalStatusLine(decision string) string {
|
|||||||
|
|
||||||
func conciseValue(value any) string {
|
func conciseValue(value any) string {
|
||||||
text := fmt.Sprint(value)
|
text := fmt.Sprint(value)
|
||||||
if data, err := json.Marshal(value); err == nil {
|
if stringValue, ok := value.(string); ok {
|
||||||
|
text = stringValue
|
||||||
|
} else if data, err := json.Marshal(value); err == nil {
|
||||||
text = string(data)
|
text = string(data)
|
||||||
}
|
}
|
||||||
text = strings.Join(strings.Fields(text), " ")
|
text = strings.Join(strings.Fields(text), " ")
|
||||||
|
|||||||
@@ -35,6 +35,119 @@ func ExpandableQuoteHTML(text string) string {
|
|||||||
return "<blockquote expandable>" + EscapeHTML(text) + "</blockquote>"
|
return "<blockquote expandable>" + EscapeHTML(text) + "</blockquote>"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExpandableQuoteRawHTML(htmlText string) string {
|
||||||
|
htmlText = strings.TrimSpace(htmlText)
|
||||||
|
if htmlText == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "<blockquote expandable>" + htmlText + "</blockquote>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func SummaryDetailsRawHTML(summary, detailsHTML string) string {
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
detailsHTML = strings.TrimSpace(detailsHTML)
|
||||||
|
if detailsHTML == "" {
|
||||||
|
return EscapeHTML(summary)
|
||||||
|
}
|
||||||
|
if summary == "" {
|
||||||
|
return ExpandableQuoteRawHTML(detailsHTML)
|
||||||
|
}
|
||||||
|
return EscapeHTML(summary) + "\n" + ExpandableQuoteRawHTML(detailsHTML)
|
||||||
|
}
|
||||||
|
|
||||||
|
func SummaryDetailsRawHTMLLimited(summary, detailsHTML string, limit int) string {
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = TelegramHTMLMessageLimit
|
||||||
|
}
|
||||||
|
summary = strings.TrimSpace(summary)
|
||||||
|
detailsHTML = strings.TrimSpace(detailsHTML)
|
||||||
|
out := SummaryDetailsRawHTML(summary, detailsHTML)
|
||||||
|
if len([]rune(out)) <= limit || detailsHTML == "" {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := stripSimpleHTML(detailsHTML)
|
||||||
|
suffix := "\n...[truncated]"
|
||||||
|
runes := []rune(plain)
|
||||||
|
for len(runes) > 0 {
|
||||||
|
candidateLen := len(runes) - max(1, (len([]rune(out))-limit)/2)
|
||||||
|
if candidateLen < 0 {
|
||||||
|
candidateLen = 0
|
||||||
|
}
|
||||||
|
if candidateLen > len(runes) {
|
||||||
|
candidateLen = len(runes)
|
||||||
|
}
|
||||||
|
candidate := CodeBlockHTML("text", strings.TrimSpace(string(runes[:candidateLen]))+suffix)
|
||||||
|
out = SummaryDetailsRawHTML(summary, candidate)
|
||||||
|
if len([]rune(out)) <= limit || candidateLen == 0 {
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
runes = runes[:candidateLen]
|
||||||
|
}
|
||||||
|
return SummaryDetailsRawHTML(summary, EscapeHTML(suffix))
|
||||||
|
}
|
||||||
|
|
||||||
|
func CodeBlockHTML(language, text string) string {
|
||||||
|
text = strings.TrimSpace(text)
|
||||||
|
if text == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
language = safeCodeLanguage(language)
|
||||||
|
return "<pre><code class=\"language-" + language + "\">" + EscapeHTML(text) + "</code></pre>"
|
||||||
|
}
|
||||||
|
|
||||||
|
func FieldHTML(label, value string) string {
|
||||||
|
value = strings.TrimSpace(value)
|
||||||
|
if value == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return "<b>" + EscapeHTML(label) + ":</b> " + EscapeHTML(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeCodeLanguage(language string) string {
|
||||||
|
language = strings.ToLower(strings.TrimSpace(language))
|
||||||
|
if language == "" {
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
var builder strings.Builder
|
||||||
|
for _, r := range language {
|
||||||
|
switch {
|
||||||
|
case r >= 'a' && r <= 'z':
|
||||||
|
builder.WriteRune(r)
|
||||||
|
case r >= '0' && r <= '9':
|
||||||
|
builder.WriteRune(r)
|
||||||
|
case r == '-', r == '_':
|
||||||
|
builder.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if builder.Len() == 0 {
|
||||||
|
return "text"
|
||||||
|
}
|
||||||
|
return builder.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func stripSimpleHTML(htmlText string) string {
|
||||||
|
replacer := strings.NewReplacer(
|
||||||
|
"<pre>", "", "</pre>", "",
|
||||||
|
"<code>", "", "</code>", "",
|
||||||
|
"<b>", "", "</b>", "",
|
||||||
|
"<i>", "", "</i>", "",
|
||||||
|
)
|
||||||
|
text := replacer.Replace(htmlText)
|
||||||
|
for {
|
||||||
|
start := strings.Index(text, "<code ")
|
||||||
|
if start < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
end := strings.Index(text[start:], ">")
|
||||||
|
if end < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
text = text[:start] + text[start+end+1:]
|
||||||
|
}
|
||||||
|
return html.UnescapeString(text)
|
||||||
|
}
|
||||||
|
|
||||||
func SummaryDetailsHTMLLimited(summary, details string, limit int) string {
|
func SummaryDetailsHTMLLimited(summary, details string, limit int) string {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = TelegramHTMLMessageLimit
|
limit = TelegramHTMLMessageLimit
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package telegram
|
package telegram
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -155,7 +156,7 @@ func TestRenderCodexCommandExecutionItem(t *testing.T) {
|
|||||||
ExitCode: &exitCode,
|
ExitCode: &exitCode,
|
||||||
}
|
}
|
||||||
text := renderCodexItemCompleted(item)
|
text := renderCodexItemCompleted(item)
|
||||||
for _, want := range []string{"Tool call: command finished", "Command: go test ./...", "Exit code: 0", "line 1"} {
|
for _, want := range []string{"Tool call: command finished", "Command: go test ./...", "Exit code: 0", "<pre><code class=\"language-text\">line 1\nline 2</code></pre>"} {
|
||||||
if !strings.Contains(text, want) {
|
if !strings.Contains(text, want) {
|
||||||
t.Fatalf("rendered command item missing %q in %q", want, text)
|
t.Fatalf("rendered command item missing %q in %q", want, text)
|
||||||
}
|
}
|
||||||
@@ -169,6 +170,38 @@ func TestRenderCodexStartedItems(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRenderDynamicToolDetailsSelectsUsefulArguments(t *testing.T) {
|
||||||
|
item := codexThreadItemView{
|
||||||
|
Type: "dynamicToolCall",
|
||||||
|
Namespace: "functions",
|
||||||
|
Tool: "exec_command",
|
||||||
|
Status: "completed",
|
||||||
|
Arguments: json.RawMessage(`{"cmd":"go test ./...","irrelevant":{"large":"object"}}`),
|
||||||
|
}
|
||||||
|
text := renderCodexItemCompleted(item)
|
||||||
|
for _, want := range []string{"Tool: functions.exec_command", "<b>cmd</b>", "language-bash", "go test ./..."} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("rendered tool details missing %q in %q", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "irrelevant") {
|
||||||
|
t.Fatalf("rendered tool details should omit irrelevant argument JSON: %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRenderApprovalDetailsAvoidsRawJSONDump(t *testing.T) {
|
||||||
|
raw := json.RawMessage(`{"command":"go test ./...","cwd":"/workspace/project","unused":{"nested":true}}`)
|
||||||
|
text := renderApprovalHTML("item/commandExecution/requestApproval", raw, "")
|
||||||
|
for _, want := range []string{"Codex requests command approval", "language-bash", "go test ./...", "CWD"} {
|
||||||
|
if !strings.Contains(text, want) {
|
||||||
|
t.Fatalf("approval render missing %q in %q", want, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Contains(text, "unused") {
|
||||||
|
t.Fatalf("approval render should omit unused JSON: %q", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) {
|
func TestToolMessageAddsEditedAtBeforeDetails(t *testing.T) {
|
||||||
tool := toolMessageState{
|
tool := toolMessageState{
|
||||||
toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"),
|
toolHTML: SummaryDetailsHTML("Tool call: command finished\nCommand: go test ./...", "full output"),
|
||||||
|
|||||||
Reference in New Issue
Block a user