tool_base.go 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. package chat
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "slices"
  6. "strings"
  7. "charm.land/bubbles/v2/key"
  8. tea "charm.land/bubbletea/v2"
  9. "charm.land/lipgloss/v2"
  10. "github.com/charmbracelet/crush/internal/ansiext"
  11. "github.com/charmbracelet/crush/internal/message"
  12. "github.com/charmbracelet/crush/internal/session"
  13. "github.com/charmbracelet/crush/internal/ui/common"
  14. "github.com/charmbracelet/crush/internal/ui/common/anim"
  15. "github.com/charmbracelet/crush/internal/ui/styles"
  16. "github.com/charmbracelet/x/ansi"
  17. )
  18. // responseContextHeight limits the number of lines displayed in tool output.
  19. const responseContextHeight = 10
  20. // ToolStatus represents the current state of a tool call.
  21. type ToolStatus int
  22. const (
  23. ToolStatusAwaitingPermission ToolStatus = iota
  24. ToolStatusRunning
  25. ToolStatusSuccess
  26. ToolStatusError
  27. ToolStatusCancelled
  28. )
  29. // ToolCallContext provides the context needed for rendering a tool call.
  30. type ToolCallContext struct {
  31. Call message.ToolCall
  32. Result *message.ToolResult
  33. Cancelled bool
  34. PermissionRequested bool
  35. PermissionGranted bool
  36. IsNested bool
  37. Styles *styles.Styles
  38. NestedCalls []ToolCallContext
  39. }
  40. // Status returns the current status of the tool call.
  41. func (ctx *ToolCallContext) Status() ToolStatus {
  42. if ctx.Cancelled {
  43. return ToolStatusCancelled
  44. }
  45. if ctx.HasResult() {
  46. if ctx.Result.IsError {
  47. return ToolStatusError
  48. }
  49. return ToolStatusSuccess
  50. }
  51. if ctx.PermissionRequested && !ctx.PermissionGranted {
  52. return ToolStatusAwaitingPermission
  53. }
  54. return ToolStatusRunning
  55. }
  56. // HasResult returns true if the tool call has a completed result.
  57. func (ctx *ToolCallContext) HasResult() bool {
  58. return ctx.Result != nil && ctx.Result.ToolCallID != ""
  59. }
  60. // toolStyles provides common FocusStylable and HighlightStylable implementations.
  61. type toolStyles struct {
  62. sty *styles.Styles
  63. }
  64. func (s toolStyles) FocusStyle() lipgloss.Style {
  65. return s.sty.Chat.Message.ToolCallFocused
  66. }
  67. func (s toolStyles) BlurStyle() lipgloss.Style {
  68. return s.sty.Chat.Message.ToolCallBlurred
  69. }
  70. func (s toolStyles) HighlightStyle() lipgloss.Style {
  71. return s.sty.TextSelection
  72. }
  73. // toolItem provides common base functionality for all tool items.
  74. type toolItem struct {
  75. toolStyles
  76. id string
  77. ctx ToolCallContext
  78. expanded bool
  79. wasTruncated bool
  80. spinning bool
  81. anim *anim.Anim
  82. }
  83. // newToolItem creates a new toolItem with the given context.
  84. func newToolItem(ctx ToolCallContext) toolItem {
  85. animSize := 15
  86. if ctx.IsNested {
  87. animSize = 10
  88. }
  89. t := toolItem{
  90. toolStyles: toolStyles{sty: ctx.Styles},
  91. id: ctx.Call.ID,
  92. ctx: ctx,
  93. spinning: shouldSpin(ctx),
  94. anim: anim.New(anim.Settings{
  95. Size: animSize,
  96. Label: "Working",
  97. GradColorA: ctx.Styles.Primary,
  98. GradColorB: ctx.Styles.Secondary,
  99. LabelColor: ctx.Styles.FgBase,
  100. CycleColors: true,
  101. }),
  102. }
  103. return t
  104. }
  105. // shouldSpin returns true if the tool should show animation.
  106. func shouldSpin(ctx ToolCallContext) bool {
  107. return !ctx.Call.Finished && !ctx.Cancelled
  108. }
  109. // ID implements Identifiable.
  110. func (t *toolItem) ID() string {
  111. return t.id
  112. }
  113. // HandleMouseClick implements list.MouseClickable.
  114. func (t *toolItem) HandleMouseClick(btn ansi.MouseButton, x, y int) bool {
  115. if btn != ansi.MouseLeft || !t.wasTruncated {
  116. return false
  117. }
  118. t.expanded = !t.expanded
  119. return true
  120. }
  121. // HandleKeyPress implements list.KeyPressable.
  122. func (t *toolItem) HandleKeyPress(msg tea.KeyPressMsg) bool {
  123. if !t.wasTruncated {
  124. return false
  125. }
  126. if key.Matches(msg, key.NewBinding(key.WithKeys("space"))) {
  127. t.expanded = !t.expanded
  128. return true
  129. }
  130. return false
  131. }
  132. // updateAnimation handles animation updates and returns true if changed.
  133. func (t *toolItem) updateAnimation(msg tea.Msg) (tea.Cmd, bool) {
  134. if !t.spinning || t.anim == nil {
  135. return nil, false
  136. }
  137. switch msg.(type) {
  138. case anim.StepMsg:
  139. updatedAnim, cmd := t.anim.Update(msg)
  140. t.anim = updatedAnim
  141. return cmd, cmd != nil
  142. }
  143. return nil, false
  144. }
  145. // InitAnimation initializes and starts the animation.
  146. func (t *toolItem) InitAnimation() tea.Cmd {
  147. t.spinning = shouldSpin(t.ctx)
  148. return t.anim.Init()
  149. }
  150. // SetResult updates the tool call with a result.
  151. func (t *toolItem) SetResult(result message.ToolResult) {
  152. t.ctx.Result = &result
  153. t.ctx.Call.Finished = true
  154. t.spinning = false
  155. }
  156. // SetCancelled marks the tool call as cancelled.
  157. func (t *toolItem) SetCancelled() {
  158. t.ctx.Cancelled = true
  159. t.spinning = false
  160. }
  161. // UpdateCall updates the tool call data.
  162. func (t *toolItem) UpdateCall(call message.ToolCall) {
  163. t.ctx.Call = call
  164. if call.Finished {
  165. t.spinning = false
  166. }
  167. }
  168. // SetNestedCalls sets the nested tool calls for agent tools.
  169. func (t *toolItem) SetNestedCalls(calls []ToolCallContext) {
  170. t.ctx.NestedCalls = calls
  171. }
  172. // Context returns the current tool call context.
  173. func (t *toolItem) Context() *ToolCallContext {
  174. return &t.ctx
  175. }
  176. // renderPending returns the pending state view with animation.
  177. func (t *toolItem) renderPending() string {
  178. icon := t.sty.Tool.IconPending.Render()
  179. var toolName string
  180. if t.ctx.IsNested {
  181. toolName = t.sty.Tool.NameNested.Render(prettifyToolName(t.ctx.Call.Name))
  182. } else {
  183. toolName = t.sty.Tool.NameNormal.Render(prettifyToolName(t.ctx.Call.Name))
  184. }
  185. var animView string
  186. if t.anim != nil {
  187. animView = t.anim.View()
  188. }
  189. return fmt.Sprintf("%s %s %s", icon, toolName, animView)
  190. }
  191. // unmarshalParams unmarshals JSON input into the target struct.
  192. func unmarshalParams(input string, target any) error {
  193. return json.Unmarshal([]byte(input), target)
  194. }
  195. // ParamBuilder helps construct parameter lists for tool headers.
  196. type ParamBuilder struct {
  197. args []string
  198. }
  199. // NewParamBuilder creates a new parameter builder.
  200. func NewParamBuilder() *ParamBuilder {
  201. return &ParamBuilder{args: make([]string, 0, 4)}
  202. }
  203. // Main adds the main parameter (first positional argument).
  204. func (pb *ParamBuilder) Main(value string) *ParamBuilder {
  205. if value != "" {
  206. pb.args = append(pb.args, value)
  207. }
  208. return pb
  209. }
  210. // KeyValue adds a key-value pair parameter.
  211. func (pb *ParamBuilder) KeyValue(key, value string) *ParamBuilder {
  212. if value != "" {
  213. pb.args = append(pb.args, key, value)
  214. }
  215. return pb
  216. }
  217. // Flag adds a boolean flag parameter (only if true).
  218. func (pb *ParamBuilder) Flag(key string, value bool) *ParamBuilder {
  219. if value {
  220. pb.args = append(pb.args, key, "true")
  221. }
  222. return pb
  223. }
  224. // Build returns the parameter list.
  225. func (pb *ParamBuilder) Build() []string {
  226. return pb.args
  227. }
  228. // renderToolIcon returns the status icon for a tool call.
  229. func renderToolIcon(status ToolStatus, sty *styles.Styles) string {
  230. switch status {
  231. case ToolStatusSuccess:
  232. return sty.Tool.IconSuccess.String()
  233. case ToolStatusError:
  234. return sty.Tool.IconError.String()
  235. case ToolStatusCancelled:
  236. return sty.Tool.IconCancelled.String()
  237. default:
  238. return sty.Tool.IconPending.String()
  239. }
  240. }
  241. // renderToolHeader builds the tool header line: "● ToolName params..."
  242. func renderToolHeader(ctx *ToolCallContext, name string, width int, params ...string) string {
  243. sty := ctx.Styles
  244. icon := renderToolIcon(ctx.Status(), sty)
  245. var toolName string
  246. if ctx.IsNested {
  247. toolName = sty.Tool.NameNested.Render(name)
  248. } else {
  249. toolName = sty.Tool.NameNormal.Render(name)
  250. }
  251. prefix := fmt.Sprintf("%s %s ", icon, toolName)
  252. prefixWidth := lipgloss.Width(prefix)
  253. remainingWidth := width - prefixWidth
  254. paramsStr := renderParamList(params, remainingWidth, sty)
  255. return prefix + paramsStr
  256. }
  257. // renderParamList formats parameters as "main (key=value, ...)" with truncation.
  258. func renderParamList(params []string, width int, sty *styles.Styles) string {
  259. if len(params) == 0 {
  260. return ""
  261. }
  262. mainParam := params[0]
  263. if width >= 0 && lipgloss.Width(mainParam) > width {
  264. mainParam = ansi.Truncate(mainParam, width, "…")
  265. }
  266. if len(params) == 1 {
  267. return sty.Tool.ParamMain.Render(mainParam)
  268. }
  269. // Build key=value pairs from remaining params.
  270. otherParams := params[1:]
  271. if len(otherParams)%2 != 0 {
  272. otherParams = append(otherParams, "")
  273. }
  274. var parts []string
  275. for i := 0; i < len(otherParams); i += 2 {
  276. key := otherParams[i]
  277. value := otherParams[i+1]
  278. if value == "" {
  279. continue
  280. }
  281. parts = append(parts, fmt.Sprintf("%s=%s", key, value))
  282. }
  283. if len(parts) == 0 {
  284. return sty.Tool.ParamMain.Render(ansi.Truncate(mainParam, width, "…"))
  285. }
  286. partsRendered := strings.Join(parts, ", ")
  287. remainingWidth := width - lipgloss.Width(partsRendered) - 3 // " ()"
  288. if remainingWidth < 30 {
  289. // Not enough space for params, just show main.
  290. return sty.Tool.ParamMain.Render(ansi.Truncate(mainParam, width, "…"))
  291. }
  292. fullParam := fmt.Sprintf("%s (%s)", mainParam, partsRendered)
  293. return sty.Tool.ParamMain.Render(ansi.Truncate(fullParam, width, "…"))
  294. }
  295. // renderEarlyState handles error/cancelled/pending states before content rendering.
  296. // Returns the rendered output and true if early state was handled.
  297. func renderEarlyState(ctx *ToolCallContext, header string, width int) (string, bool) {
  298. sty := ctx.Styles
  299. var msg string
  300. switch ctx.Status() {
  301. case ToolStatusError:
  302. msg = renderToolError(ctx, width)
  303. case ToolStatusCancelled:
  304. msg = sty.Tool.StateCancelled.Render("Canceled.")
  305. case ToolStatusAwaitingPermission:
  306. msg = sty.Tool.StateWaiting.Render("Requesting permission...")
  307. case ToolStatusRunning:
  308. msg = sty.Tool.StateWaiting.Render("Waiting for tool response...")
  309. default:
  310. return "", false
  311. }
  312. msg = sty.Tool.BodyPadding.Render(msg)
  313. return lipgloss.JoinVertical(lipgloss.Left, header, "", msg), true
  314. }
  315. // renderToolError formats an error message with ERROR tag.
  316. func renderToolError(ctx *ToolCallContext, width int) string {
  317. sty := ctx.Styles
  318. errContent := strings.ReplaceAll(ctx.Result.Content, "\n", " ")
  319. errTag := sty.Tool.ErrorTag.Render("ERROR")
  320. tagWidth := lipgloss.Width(errTag)
  321. errContent = ansi.Truncate(errContent, width-tagWidth-3, "…")
  322. return fmt.Sprintf("%s %s", errTag, sty.Tool.ErrorMessage.Render(errContent))
  323. }
  324. // joinHeaderBody combines header and body with proper padding.
  325. func joinHeaderBody(header, body string, sty *styles.Styles) string {
  326. if body == "" {
  327. return header
  328. }
  329. body = sty.Tool.BodyPadding.Render(body)
  330. return lipgloss.JoinVertical(lipgloss.Left, header, "", body)
  331. }
  332. // renderPlainContent renders plain text with optional expansion support.
  333. func renderPlainContent(content string, width int, sty *styles.Styles, item *toolItem) string {
  334. content = strings.ReplaceAll(content, "\r\n", "\n")
  335. content = strings.ReplaceAll(content, "\t", " ")
  336. content = strings.TrimSpace(content)
  337. lines := strings.Split(content, "\n")
  338. expanded := item != nil && item.expanded
  339. maxLines := responseContextHeight
  340. if expanded {
  341. maxLines = len(lines) // Show all
  342. }
  343. var out []string
  344. for i, ln := range lines {
  345. if i >= maxLines {
  346. break
  347. }
  348. ln = " " + ln
  349. if lipgloss.Width(ln) > width {
  350. ln = ansi.Truncate(ln, width, "…")
  351. }
  352. out = append(out, sty.Tool.ContentLine.Width(width).Render(ln))
  353. }
  354. wasTruncated := len(lines) > responseContextHeight
  355. if item != nil {
  356. item.wasTruncated = wasTruncated
  357. }
  358. if !expanded && wasTruncated {
  359. out = append(out, sty.Tool.ContentTruncation.
  360. Width(width).
  361. Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
  362. }
  363. return strings.Join(out, "\n")
  364. }
  365. // formatNonZero returns string representation of non-zero integers, empty for zero.
  366. func formatNonZero(value int) string {
  367. if value == 0 {
  368. return ""
  369. }
  370. return fmt.Sprintf("%d", value)
  371. }
  372. // renderCodeContent renders syntax-highlighted code with line numbers and optional expansion.
  373. func renderCodeContent(path, content string, offset, width int, sty *styles.Styles, item *toolItem) string {
  374. content = strings.ReplaceAll(content, "\r\n", "\n")
  375. content = strings.ReplaceAll(content, "\t", " ")
  376. lines := strings.Split(content, "\n")
  377. maxLines := responseContextHeight
  378. if item != nil && item.expanded {
  379. maxLines = len(lines)
  380. }
  381. truncated := lines
  382. if len(lines) > maxLines {
  383. truncated = lines[:maxLines]
  384. }
  385. // Escape ANSI sequences in content.
  386. for i, ln := range truncated {
  387. truncated[i] = ansiext.Escape(ln)
  388. }
  389. // Apply syntax highlighting.
  390. bg := sty.Tool.ContentCodeBg
  391. highlighted, _ := common.SyntaxHighlight(sty, strings.Join(truncated, "\n"), path, bg)
  392. highlightedLines := strings.Split(highlighted, "\n")
  393. // Calculate gutter width for line numbers.
  394. maxLineNum := offset + len(highlightedLines)
  395. maxDigits := getDigits(maxLineNum)
  396. numFmt := fmt.Sprintf("%%%dd", maxDigits)
  397. // Calculate available width for code (accounting for gutter).
  398. const numPR, numPL, codePR, codePL = 1, 1, 1, 2
  399. codeWidth := width - maxDigits - numPL - numPR - 2
  400. var out []string
  401. for i, ln := range highlightedLines {
  402. lineNum := sty.Base.
  403. Foreground(sty.FgMuted).
  404. Background(bg).
  405. PaddingRight(numPR).
  406. PaddingLeft(numPL).
  407. Render(fmt.Sprintf(numFmt, offset+i+1))
  408. codeLine := sty.Base.
  409. Width(codeWidth).
  410. Background(bg).
  411. PaddingRight(codePR).
  412. PaddingLeft(codePL).
  413. Render(ansi.Truncate(ln, codeWidth-codePL-codePR, "…"))
  414. out = append(out, lipgloss.JoinHorizontal(lipgloss.Left, lineNum, codeLine))
  415. }
  416. wasTruncated := len(lines) > responseContextHeight
  417. if item != nil {
  418. item.wasTruncated = wasTruncated
  419. }
  420. expanded := item != nil && item.expanded
  421. if !expanded && wasTruncated {
  422. msg := fmt.Sprintf(" …(%d lines) [click or space to expand]", len(lines)-responseContextHeight)
  423. out = append(out, sty.Muted.Background(bg).Render(msg))
  424. }
  425. return lipgloss.JoinVertical(lipgloss.Left, out...)
  426. }
  427. // renderMarkdownContent renders markdown with optional expansion support.
  428. func renderMarkdownContent(content string, width int, sty *styles.Styles, item *toolItem) string {
  429. content = strings.ReplaceAll(content, "\r\n", "\n")
  430. content = strings.ReplaceAll(content, "\t", " ")
  431. content = strings.TrimSpace(content)
  432. cappedWidth := min(width, 120)
  433. renderer := common.PlainMarkdownRenderer(sty, cappedWidth)
  434. rendered, err := renderer.Render(content)
  435. if err != nil {
  436. return renderPlainContent(content, width, sty, nil)
  437. }
  438. lines := strings.Split(rendered, "\n")
  439. maxLines := responseContextHeight
  440. if item != nil && item.expanded {
  441. maxLines = len(lines)
  442. }
  443. var out []string
  444. for i, ln := range lines {
  445. if i >= maxLines {
  446. break
  447. }
  448. out = append(out, ln)
  449. }
  450. wasTruncated := len(lines) > responseContextHeight
  451. if item != nil {
  452. item.wasTruncated = wasTruncated
  453. }
  454. expanded := item != nil && item.expanded
  455. if !expanded && wasTruncated {
  456. out = append(out, sty.Tool.ContentTruncation.
  457. Width(cappedWidth-2).
  458. Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight)))
  459. }
  460. return sty.Tool.ContentLine.Render(strings.Join(out, "\n"))
  461. }
  462. // renderDiffContent renders a diff with optional expansion support.
  463. func renderDiffContent(file, oldContent, newContent string, width int, sty *styles.Styles, item *toolItem) string {
  464. formatter := common.DiffFormatter(sty).
  465. Before(file, oldContent).
  466. After(file, newContent).
  467. Width(width)
  468. if width > 120 {
  469. formatter = formatter.Split()
  470. }
  471. formatted := formatter.String()
  472. lines := strings.Split(formatted, "\n")
  473. wasTruncated := len(lines) > responseContextHeight
  474. if item != nil {
  475. item.wasTruncated = wasTruncated
  476. }
  477. expanded := item != nil && item.expanded
  478. if !expanded && wasTruncated {
  479. truncateMsg := sty.Tool.DiffTruncation.
  480. Width(width).
  481. Render(fmt.Sprintf("… (%d lines) [click or space to expand]", len(lines)-responseContextHeight))
  482. formatted = strings.Join(lines[:responseContextHeight], "\n") + "\n" + truncateMsg
  483. }
  484. return formatted
  485. }
  486. // renderImageContent renders image data with optional text content.
  487. func renderImageContent(data, mediaType, textContent string, sty *styles.Styles) string {
  488. dataSize := len(data) * 3 / 4 // Base64 to bytes approximation.
  489. sizeStr := formatSize(dataSize)
  490. loaded := sty.Tool.IconSuccess.String()
  491. arrow := sty.Tool.NameNested.Render("→")
  492. typeStyled := sty.Base.Render(mediaType)
  493. sizeStyled := sty.Subtle.Render(sizeStr)
  494. imageDisplay := fmt.Sprintf("%s %s %s %s", loaded, arrow, typeStyled, sizeStyled)
  495. if strings.TrimSpace(textContent) != "" {
  496. textDisplay := sty.Tool.ContentLine.Render(textContent)
  497. return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", imageDisplay)
  498. }
  499. return imageDisplay
  500. }
  501. // renderMediaContent renders non-image media content.
  502. func renderMediaContent(mediaType, textContent string, sty *styles.Styles) string {
  503. loaded := sty.Tool.IconSuccess.String()
  504. arrow := sty.Tool.NameNested.Render("→")
  505. typeStyled := sty.Base.Render(mediaType)
  506. mediaDisplay := fmt.Sprintf("%s %s %s", loaded, arrow, typeStyled)
  507. if strings.TrimSpace(textContent) != "" {
  508. textDisplay := sty.Tool.ContentLine.Render(textContent)
  509. return lipgloss.JoinVertical(lipgloss.Left, textDisplay, "", mediaDisplay)
  510. }
  511. return mediaDisplay
  512. }
  513. // formatSize formats byte count as human-readable size.
  514. func formatSize(bytes int) string {
  515. if bytes < 1024 {
  516. return fmt.Sprintf("%d B", bytes)
  517. }
  518. if bytes < 1024*1024 {
  519. return fmt.Sprintf("%.1f KB", float64(bytes)/1024)
  520. }
  521. return fmt.Sprintf("%.1f MB", float64(bytes)/(1024*1024))
  522. }
  523. // getDigits returns the number of digits in a number.
  524. func getDigits(n int) int {
  525. if n == 0 {
  526. return 1
  527. }
  528. if n < 0 {
  529. n = -n
  530. }
  531. digits := 0
  532. for n > 0 {
  533. n /= 10
  534. digits++
  535. }
  536. return digits
  537. }
  538. // formatTodosList formats a list of todos with status icons.
  539. func formatTodosList(todos []session.Todo, width int, sty *styles.Styles) string {
  540. if len(todos) == 0 {
  541. return ""
  542. }
  543. sorted := make([]session.Todo, len(todos))
  544. copy(sorted, todos)
  545. slices.SortStableFunc(sorted, func(a, b session.Todo) int {
  546. return todoStatusOrder(a.Status) - todoStatusOrder(b.Status)
  547. })
  548. var lines []string
  549. for _, todo := range sorted {
  550. var prefix string
  551. var textStyle lipgloss.Style
  552. switch todo.Status {
  553. case session.TodoStatusCompleted:
  554. prefix = sty.Base.Foreground(sty.Green).Render(styles.TodoCompletedIcon) + " "
  555. textStyle = sty.Base
  556. case session.TodoStatusInProgress:
  557. prefix = sty.Base.Foreground(sty.GreenDark).Render(styles.ArrowRightIcon) + " "
  558. textStyle = sty.Base
  559. default:
  560. prefix = sty.Muted.Render(styles.TodoPendingIcon) + " "
  561. textStyle = sty.Base
  562. }
  563. text := todo.Content
  564. if todo.Status == session.TodoStatusInProgress && todo.ActiveForm != "" {
  565. text = todo.ActiveForm
  566. }
  567. line := prefix + textStyle.Render(text)
  568. line = ansi.Truncate(line, width, "…")
  569. lines = append(lines, line)
  570. }
  571. return strings.Join(lines, "\n")
  572. }
  573. // todoStatusOrder returns sort order for todo statuses.
  574. func todoStatusOrder(s session.TodoStatus) int {
  575. switch s {
  576. case session.TodoStatusCompleted:
  577. return 0
  578. case session.TodoStatusInProgress:
  579. return 1
  580. default:
  581. return 2
  582. }
  583. }