add collapsible code blocks
- commit
- dbe6763736605fe78d63f0fcc4596cd07417e222
- author
- patriceckhart · · 2026-04-18 08:30 UTC
- parent
- 6bb3e9e
- signature
- unsigned
4 files · +260 -22
M internal/agent/modes/help.go +1 -0
| @@ -20,6 +20,7 @@ var helpKeyRows = [][2]string{ | |||
| 20 | 20 | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | |
| 21 | 21 | {"alt+← / alt+→", "jump one word back / forward"}, | |
| 22 | 22 | {"ctrl+l", "redraw the screen"}, | |
| 23 | + | {"ctrl+o", "expand / collapse long tool results"}, | |
| 23 | 24 | {"pgup / pgdn", "scroll the chat one page up / down"}, | |
| 24 | 25 | {"up / down", "scroll by 3 lines (when input is empty) · prompt history (otherwise)"}, | |
| 25 | 26 | } | |
| @@ -20,6 +20,7 @@ var helpKeyRows = [][2]string{ | |||||
| 20 | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | 20 | {"ctrl+a / ctrl+e", "jump to start / end of line"}, | ||
| 21 | {"alt+← / alt+→", "jump one word back / forward"}, | 21 | {"alt+← / alt+→", "jump one word back / forward"}, | ||
| 22 | {"ctrl+l", "redraw the screen"}, | 22 | {"ctrl+l", "redraw the screen"}, | ||
| 23 | + | {"ctrl+o", "expand / collapse long tool results"}, | |||
| 23 | {"pgup / pgdn", "scroll the chat one page up / down"}, | 24 | {"pgup / pgdn", "scroll the chat one page up / down"}, | ||
| 24 | {"up / down", "scroll by 3 lines (when input is empty) · prompt history (otherwise)"}, | 25 | {"up / down", "scroll by 3 lines (when input is empty) · prompt history (otherwise)"}, | ||
| 25 | } | 26 | } | ||
M internal/agent/modes/interactive.go +99 -5
| @@ -90,6 +90,16 @@ type Interactive struct { | |||
| 90 | 90 | cancelTurn context.CancelFunc | |
| 91 | 91 | scrollOffset int // rows from the bottom; 0 = pinned to latest | |
| 92 | 92 | ||
| 93 | + | // Messages typed while a turn is in flight. Each is delivered as | |
| 94 | + | // its own follow-up turn once the current one finishes. Rendered | |
| 95 | + | // above the status bar as "sliding in: ..." chips. | |
| 96 | + | queued []string | |
| 97 | + | ||
| 98 | + | // runCtx is the top-level context passed to Run(). Follow-up turns | |
| 99 | + | // drained from `queued` are started against this context so they | |
| 100 | + | // survive past the ctx of the key event that enqueued them. | |
| 101 | + | runCtx context.Context | |
| 102 | + | ||
| 93 | 103 | dialog *loginDialog | |
| 94 | 104 | modelDialog *modelDialog | |
| 95 | 105 | sessionDialog *sessionDialog | |
| @@ -124,6 +134,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||
| 124 | 134 | ||
| 125 | 135 | // Run blocks until the user quits. | |
| 126 | 136 | func (i *Interactive) Run(ctx context.Context) error { | |
| 137 | + | i.runCtx = ctx | |
| 127 | 138 | term := i.cfg.Terminal | |
| 128 | 139 | restore, err := term.EnterRaw() | |
| 129 | 140 | if err != nil { | |
| @@ -391,10 +402,23 @@ func (i *Interactive) redraw() { | |||
| 391 | 402 | }) | |
| 392 | 403 | edLines, curR, curC := i.ed.Render(cols) | |
| 393 | 404 | ||
| 405 | + | // "Sliding in" chips for messages the user typed while a turn is | |
| 406 | + | // in flight. Shown directly above the status bar so they're close | |
| 407 | + | // to the editor but don't push the chat around. | |
| 408 | + | var queue []string | |
| 409 | + | if len(i.queued) > 0 { | |
| 410 | + | for _, q := range i.queued { | |
| 411 | + | label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ") | |
| 412 | + | text := truncateLine(q, cols-15) | |
| 413 | + | queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 394 | 417 | // Bottom-sticky sections (always visible, never scroll). | |
| 395 | - | bottom := make([]string, 0, len(dialog)+len(suggest)+len(edLines)+1) | |
| 418 | + | bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1) | |
| 396 | 419 | bottom = append(bottom, dialog...) | |
| 397 | 420 | bottom = append(bottom, suggest...) | |
| 421 | + | bottom = append(bottom, queue...) | |
| 398 | 422 | bottom = append(bottom, status) | |
| 399 | 423 | bottom = append(bottom, edLines...) | |
| 400 | 424 | ||
| @@ -443,11 +467,30 @@ func (i *Interactive) redraw() { | |||
| 443 | 467 | frame = append(frame, visibleChat...) | |
| 444 | 468 | frame = append(frame, bottom...) | |
| 445 | 469 | ||
| 446 | - | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + 1 + curR | |
| 470 | + | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR | |
| 447 | 471 | cursorCol := curC | |
| 448 | 472 | i.rend.Draw(frame, cursorRow, cursorCol) | |
| 449 | 473 | } | |
| 450 | 474 | ||
| 475 | + | // truncateLine shortens s so it fits within n display cells, with an | |
| 476 | + | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | |
| 477 | + | // novel doesn't blow past the status line. | |
| 478 | + | func truncateLine(s string, n int) string { | |
| 479 | + | if n <= 0 { | |
| 480 | + | return "" | |
| 481 | + | } | |
| 482 | + | // Collapse newlines — chips are single line. | |
| 483 | + | s = strings.ReplaceAll(s, "\n", " ↩ ") | |
| 484 | + | runes := []rune(s) | |
| 485 | + | if len(runes) <= n { | |
| 486 | + | return s | |
| 487 | + | } | |
| 488 | + | if n <= 1 { | |
| 489 | + | return "…" | |
| 490 | + | } | |
| 491 | + | return string(runes[:n-1]) + "…" | |
| 492 | + | } | |
| 493 | + | ||
| 451 | 494 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |
| 452 | 495 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | |
| 453 | 496 | if i.dialog.Active() { | |
| @@ -513,6 +556,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 513 | 556 | i.rend.Clear() | |
| 514 | 557 | i.invalidate() | |
| 515 | 558 | return false | |
| 559 | + | case tui.KeyCtrlO: | |
| 560 | + | // Toggle expansion of collapsed tool results. Affects every tool | |
| 561 | + | // call in the transcript — press again to re-collapse. | |
| 562 | + | i.mu.Lock() | |
| 563 | + | i.view.ExpandAll = !i.view.ExpandAll | |
| 564 | + | i.mu.Unlock() | |
| 565 | + | i.invalidate() | |
| 566 | + | return false | |
| 516 | 567 | case tui.KeyPageUp: | |
| 517 | 568 | i.scrollBy(+i.chatPage()) | |
| 518 | 569 | return false | |
| @@ -535,9 +586,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 535 | 586 | } | |
| 536 | 587 | } | |
| 537 | 588 | ||
| 538 | - | if i.busy { | |
| 539 | - | return false | |
| 540 | - | } | |
| 589 | + | // Note: we intentionally do NOT gate the editor on i.busy here. | |
| 590 | + | // Typing while the agent is working is supported — submitted | |
| 591 | + | // messages are queued and delivered as follow-up turns when the | |
| 592 | + | // current turn ends. See the submit handler below. | |
| 541 | 593 | ||
| 542 | 594 | if k.Kind == tui.KeyEnter && k.Alt { | |
| 543 | 595 | i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true}) | |
| @@ -600,6 +652,18 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 600 | 652 | i.mu.Unlock() | |
| 601 | 653 | return false | |
| 602 | 654 | } | |
| 655 | + | // Slash commands need a quiet state (they may swap models, | |
| 656 | + | // compact the transcript, open dialogs, etc). Refuse while | |
| 657 | + | // a turn is in flight — esc / ctrl+c cancels first. | |
| 658 | + | i.mu.Lock() | |
| 659 | + | busy := i.busy | |
| 660 | + | i.mu.Unlock() | |
| 661 | + | if busy { | |
| 662 | + | i.mu.Lock() | |
| 663 | + | i.statusErr = "cancel the current turn (esc) before running a slash command" | |
| 664 | + | i.mu.Unlock() | |
| 665 | + | return false | |
| 666 | + | } | |
| 603 | 667 | return i.runSlash(ctx, text) | |
| 604 | 668 | } | |
| 605 | 669 | ||
| @@ -609,6 +673,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||
| 609 | 673 | i.mu.Unlock() | |
| 610 | 674 | return false | |
| 611 | 675 | } | |
| 676 | + | // If a turn is already in flight, queue this prompt instead of | |
| 677 | + | // starting a second one. The drain loop at the end of startTurn | |
| 678 | + | // will pick it up when the current turn finishes. | |
| 679 | + | i.mu.Lock() | |
| 680 | + | if i.busy { | |
| 681 | + | i.queued = append(i.queued, text) | |
| 682 | + | i.mu.Unlock() | |
| 683 | + | i.invalidate() | |
| 684 | + | return false | |
| 685 | + | } | |
| 686 | + | i.mu.Unlock() | |
| 612 | 687 | i.startTurn(ctx, text) | |
| 613 | 688 | } | |
| 614 | 689 | return false | |
| @@ -958,8 +1033,27 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) { | |||
| 958 | 1033 | if err != nil && ctx.Err() == nil { | |
| 959 | 1034 | i.statusErr = err.Error() | |
| 960 | 1035 | } | |
| 1036 | + | // Pop the next queued message, if any, and relaunch. | |
| 1037 | + | var next string | |
| 1038 | + | var hasNext bool | |
| 1039 | + | if len(i.queued) > 0 && ctx.Err() == nil && err == nil { | |
| 1040 | + | next, i.queued = i.queued[0], i.queued[1:] | |
| 1041 | + | hasNext = true | |
| 1042 | + | } | |
| 1043 | + | // If the turn was cancelled or errored, drop the queue so the | |
| 1044 | + | // user isn't bombarded with stale messages after an interrupt. | |
| 1045 | + | if ctx.Err() != nil || err != nil { | |
| 1046 | + | i.queued = nil | |
| 1047 | + | } | |
| 961 | 1048 | i.mu.Unlock() | |
| 962 | 1049 | i.invalidate() | |
| 1050 | + | if hasNext { | |
| 1051 | + | parent := i.runCtx | |
| 1052 | + | if parent == nil { | |
| 1053 | + | parent = context.Background() | |
| 1054 | + | } | |
| 1055 | + | i.startTurn(parent, next) | |
| 1056 | + | } | |
| 963 | 1057 | }() | |
| 964 | 1058 | } | |
| 965 | 1059 | ||
| @@ -90,6 +90,16 @@ type Interactive struct { | |||||
| 90 | cancelTurn context.CancelFunc | 90 | cancelTurn context.CancelFunc | ||
| 91 | scrollOffset int // rows from the bottom; 0 = pinned to latest | 91 | scrollOffset int // rows from the bottom; 0 = pinned to latest | ||
| 92 | 92 | ||||
| 93 | + | // Messages typed while a turn is in flight. Each is delivered as | |||
| 94 | + | // its own follow-up turn once the current one finishes. Rendered | |||
| 95 | + | // above the status bar as "sliding in: ..." chips. | |||
| 96 | + | queued []string | |||
| 97 | + | ||||
| 98 | + | // runCtx is the top-level context passed to Run(). Follow-up turns | |||
| 99 | + | // drained from `queued` are started against this context so they | |||
| 100 | + | // survive past the ctx of the key event that enqueued them. | |||
| 101 | + | runCtx context.Context | |||
| 102 | + | ||||
| 93 | dialog *loginDialog | 103 | dialog *loginDialog | ||
| 94 | modelDialog *modelDialog | 104 | modelDialog *modelDialog | ||
| 95 | sessionDialog *sessionDialog | 105 | sessionDialog *sessionDialog | ||
| @@ -124,6 +134,7 @@ func NewInteractive(cfg InteractiveConfig) *Interactive { | |||||
| 124 | 134 | ||||
| 125 | // Run blocks until the user quits. | 135 | // Run blocks until the user quits. | ||
| 126 | func (i *Interactive) Run(ctx context.Context) error { | 136 | func (i *Interactive) Run(ctx context.Context) error { | ||
| 137 | + | i.runCtx = ctx | |||
| 127 | term := i.cfg.Terminal | 138 | term := i.cfg.Terminal | ||
| 128 | restore, err := term.EnterRaw() | 139 | restore, err := term.EnterRaw() | ||
| 129 | if err != nil { | 140 | if err != nil { | ||
| @@ -391,10 +402,23 @@ func (i *Interactive) redraw() { | |||||
| 391 | }) | 402 | }) | ||
| 392 | edLines, curR, curC := i.ed.Render(cols) | 403 | edLines, curR, curC := i.ed.Render(cols) | ||
| 393 | 404 | ||||
| 405 | + | // "Sliding in" chips for messages the user typed while a turn is | |||
| 406 | + | // in flight. Shown directly above the status bar so they're close | |||
| 407 | + | // to the editor but don't push the chat around. | |||
| 408 | + | var queue []string | |||
| 409 | + | if len(i.queued) > 0 { | |||
| 410 | + | for _, q := range i.queued { | |||
| 411 | + | label := i.cfg.Theme.FG256(i.cfg.Theme.Accent, "▸ sliding in: ") | |||
| 412 | + | text := truncateLine(q, cols-15) | |||
| 413 | + | queue = append(queue, label+i.cfg.Theme.FG256(i.cfg.Theme.Muted, text)) | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | ||||
| 394 | // Bottom-sticky sections (always visible, never scroll). | 417 | // Bottom-sticky sections (always visible, never scroll). | ||
| 395 | - | bottom := make([]string, 0, len(dialog)+len(suggest)+len(edLines)+1) | 418 | + | bottom := make([]string, 0, len(dialog)+len(suggest)+len(queue)+len(edLines)+1) |
| 396 | bottom = append(bottom, dialog...) | 419 | bottom = append(bottom, dialog...) | ||
| 397 | bottom = append(bottom, suggest...) | 420 | bottom = append(bottom, suggest...) | ||
| 421 | + | bottom = append(bottom, queue...) | |||
| 398 | bottom = append(bottom, status) | 422 | bottom = append(bottom, status) | ||
| 399 | bottom = append(bottom, edLines...) | 423 | bottom = append(bottom, edLines...) | ||
| 400 | 424 | ||||
| @@ -443,11 +467,30 @@ func (i *Interactive) redraw() { | |||||
| 443 | frame = append(frame, visibleChat...) | 467 | frame = append(frame, visibleChat...) | ||
| 444 | frame = append(frame, bottom...) | 468 | frame = append(frame, bottom...) | ||
| 445 | 469 | ||||
| 446 | - | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + 1 + curR | 470 | + | cursorRow := len(visibleChat) + len(dialog) + len(suggest) + len(queue) + 1 + curR |
| 447 | cursorCol := curC | 471 | cursorCol := curC | ||
| 448 | i.rend.Draw(frame, cursorRow, cursorCol) | 472 | i.rend.Draw(frame, cursorRow, cursorCol) | ||
| 449 | } | 473 | } | ||
| 450 | 474 | ||||
| 475 | + | // truncateLine shortens s so it fits within n display cells, with an | |||
| 476 | + | // ellipsis if trimmed. Used by the "sliding in" chips so a pasted | |||
| 477 | + | // novel doesn't blow past the status line. | |||
| 478 | + | func truncateLine(s string, n int) string { | |||
| 479 | + | if n <= 0 { | |||
| 480 | + | return "" | |||
| 481 | + | } | |||
| 482 | + | // Collapse newlines — chips are single line. | |||
| 483 | + | s = strings.ReplaceAll(s, "\n", " ↩ ") | |||
| 484 | + | runes := []rune(s) | |||
| 485 | + | if len(runes) <= n { | |||
| 486 | + | return s | |||
| 487 | + | } | |||
| 488 | + | if n <= 1 { | |||
| 489 | + | return "…" | |||
| 490 | + | } | |||
| 491 | + | return string(runes[:n-1]) + "…" | |||
| 492 | + | } | |||
| 493 | + | ||||
| 451 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | 494 | func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | ||
| 452 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | 495 | // Dialogs consume keys while open (except ctrl+c, which always closes them). | ||
| 453 | if i.dialog.Active() { | 496 | if i.dialog.Active() { | ||
| @@ -513,6 +556,14 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 513 | i.rend.Clear() | 556 | i.rend.Clear() | ||
| 514 | i.invalidate() | 557 | i.invalidate() | ||
| 515 | return false | 558 | return false | ||
| 559 | + | case tui.KeyCtrlO: | |||
| 560 | + | // Toggle expansion of collapsed tool results. Affects every tool | |||
| 561 | + | // call in the transcript — press again to re-collapse. | |||
| 562 | + | i.mu.Lock() | |||
| 563 | + | i.view.ExpandAll = !i.view.ExpandAll | |||
| 564 | + | i.mu.Unlock() | |||
| 565 | + | i.invalidate() | |||
| 566 | + | return false | |||
| 516 | case tui.KeyPageUp: | 567 | case tui.KeyPageUp: | ||
| 517 | i.scrollBy(+i.chatPage()) | 568 | i.scrollBy(+i.chatPage()) | ||
| 518 | return false | 569 | return false | ||
| @@ -535,9 +586,10 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 535 | } | 586 | } | ||
| 536 | } | 587 | } | ||
| 537 | 588 | ||||
| 538 | - | if i.busy { | 589 | + | // Note: we intentionally do NOT gate the editor on i.busy here. |
| 539 | - | return false | 590 | + | // Typing while the agent is working is supported — submitted |
| 540 | - | } | 591 | + | // messages are queued and delivered as follow-up turns when the |
| 592 | + | // current turn ends. See the submit handler below. | |||
| 541 | 593 | ||||
| 542 | if k.Kind == tui.KeyEnter && k.Alt { | 594 | if k.Kind == tui.KeyEnter && k.Alt { | ||
| 543 | i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true}) | 595 | i.ed.HandleKey(tui.Key{Kind: tui.KeyRune, Rune: '\n', Alt: true}) | ||
| @@ -600,6 +652,18 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 600 | i.mu.Unlock() | 652 | i.mu.Unlock() | ||
| 601 | return false | 653 | return false | ||
| 602 | } | 654 | } | ||
| 655 | + | // Slash commands need a quiet state (they may swap models, | |||
| 656 | + | // compact the transcript, open dialogs, etc). Refuse while | |||
| 657 | + | // a turn is in flight — esc / ctrl+c cancels first. | |||
| 658 | + | i.mu.Lock() | |||
| 659 | + | busy := i.busy | |||
| 660 | + | i.mu.Unlock() | |||
| 661 | + | if busy { | |||
| 662 | + | i.mu.Lock() | |||
| 663 | + | i.statusErr = "cancel the current turn (esc) before running a slash command" | |||
| 664 | + | i.mu.Unlock() | |||
| 665 | + | return false | |||
| 666 | + | } | |||
| 603 | return i.runSlash(ctx, text) | 667 | return i.runSlash(ctx, text) | ||
| 604 | } | 668 | } | ||
| 605 | 669 | ||||
| @@ -609,6 +673,17 @@ func (i *Interactive) handleKey(ctx context.Context, k tui.Key) (done bool) { | |||||
| 609 | i.mu.Unlock() | 673 | i.mu.Unlock() | ||
| 610 | return false | 674 | return false | ||
| 611 | } | 675 | } | ||
| 676 | + | // If a turn is already in flight, queue this prompt instead of | |||
| 677 | + | // starting a second one. The drain loop at the end of startTurn | |||
| 678 | + | // will pick it up when the current turn finishes. | |||
| 679 | + | i.mu.Lock() | |||
| 680 | + | if i.busy { | |||
| 681 | + | i.queued = append(i.queued, text) | |||
| 682 | + | i.mu.Unlock() | |||
| 683 | + | i.invalidate() | |||
| 684 | + | return false | |||
| 685 | + | } | |||
| 686 | + | i.mu.Unlock() | |||
| 612 | i.startTurn(ctx, text) | 687 | i.startTurn(ctx, text) | ||
| 613 | } | 688 | } | ||
| 614 | return false | 689 | return false | ||
| @@ -958,8 +1033,27 @@ func (i *Interactive) startTurn(parent context.Context, prompt string) { | |||||
| 958 | if err != nil && ctx.Err() == nil { | 1033 | if err != nil && ctx.Err() == nil { | ||
| 959 | i.statusErr = err.Error() | 1034 | i.statusErr = err.Error() | ||
| 960 | } | 1035 | } | ||
| 1036 | + | // Pop the next queued message, if any, and relaunch. | |||
| 1037 | + | var next string | |||
| 1038 | + | var hasNext bool | |||
| 1039 | + | if len(i.queued) > 0 && ctx.Err() == nil && err == nil { | |||
| 1040 | + | next, i.queued = i.queued[0], i.queued[1:] | |||
| 1041 | + | hasNext = true | |||
| 1042 | + | } | |||
| 1043 | + | // If the turn was cancelled or errored, drop the queue so the | |||
| 1044 | + | // user isn't bombarded with stale messages after an interrupt. | |||
| 1045 | + | if ctx.Err() != nil || err != nil { | |||
| 1046 | + | i.queued = nil | |||
| 1047 | + | } | |||
| 961 | i.mu.Unlock() | 1048 | i.mu.Unlock() | ||
| 962 | i.invalidate() | 1049 | i.invalidate() | ||
| 1050 | + | if hasNext { | |||
| 1051 | + | parent := i.runCtx | |||
| 1052 | + | if parent == nil { | |||
| 1053 | + | parent = context.Background() | |||
| 1054 | + | } | |||
| 1055 | + | i.startTurn(parent, next) | |||
| 1056 | + | } | |||
| 963 | }() | 1057 | }() | ||
| 964 | } | 1058 | } | ||
| 965 | 1059 | ||||
M internal/tui/input.go +3 -0
| @@ -40,6 +40,7 @@ const ( | |||
| 40 | 40 | KeyCtrlA | |
| 41 | 41 | KeyCtrlE | |
| 42 | 42 | KeyCtrlW | |
| 43 | + | KeyCtrlO | |
| 43 | 44 | KeyPaste | |
| 44 | 45 | KeyUnknown | |
| 45 | 46 | ) | |
| @@ -83,6 +84,8 @@ func (r *Reader) Read() (Key, error) { | |||
| 83 | 84 | return Key{Kind: KeyCtrlE}, nil | |
| 84 | 85 | case b == 0x17: | |
| 85 | 86 | return Key{Kind: KeyCtrlW}, nil | |
| 87 | + | case b == 0x0f: | |
| 88 | + | return Key{Kind: KeyCtrlO}, nil | |
| 86 | 89 | case b == '\r', b == '\n': | |
| 87 | 90 | return Key{Kind: KeyEnter}, nil | |
| 88 | 91 | case b == '\t': | |
| @@ -40,6 +40,7 @@ const ( | |||||
| 40 | KeyCtrlA | 40 | KeyCtrlA | ||
| 41 | KeyCtrlE | 41 | KeyCtrlE | ||
| 42 | KeyCtrlW | 42 | KeyCtrlW | ||
| 43 | + | KeyCtrlO | |||
| 43 | KeyPaste | 44 | KeyPaste | ||
| 44 | KeyUnknown | 45 | KeyUnknown | ||
| 45 | ) | 46 | ) | ||
| @@ -83,6 +84,8 @@ func (r *Reader) Read() (Key, error) { | |||||
| 83 | return Key{Kind: KeyCtrlE}, nil | 84 | return Key{Kind: KeyCtrlE}, nil | ||
| 84 | case b == 0x17: | 85 | case b == 0x17: | ||
| 85 | return Key{Kind: KeyCtrlW}, nil | 86 | return Key{Kind: KeyCtrlW}, nil | ||
| 87 | + | case b == 0x0f: | |||
| 88 | + | return Key{Kind: KeyCtrlO}, nil | |||
| 86 | case b == '\r', b == '\n': | 89 | case b == '\r', b == '\n': | ||
| 87 | return Key{Kind: KeyEnter}, nil | 90 | return Key{Kind: KeyEnter}, nil | ||
| 88 | case b == '\t': | 91 | case b == '\t': | ||
M internal/tui/view.go +157 -17
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "os" | |
| 7 | 7 | "regexp" | |
| 8 | + | "strconv" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| 10 | 11 | "github.com/patriceckhart/zot/internal/provider" | |
| @@ -48,8 +49,22 @@ type View struct { | |||
| 48 | 49 | ToolCalls []ToolCallView // tool calls in flight or completed | |
| 49 | 50 | StatusLine string | |
| 50 | 51 | Err string | |
| 52 | + | ||
| 53 | + | // ExpandAll forces every long tool result to render in full. | |
| 54 | + | // Toggled from the tui by ctrl+o. When false, results longer than | |
| 55 | + | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | |
| 56 | + | // "... (N more lines, M total, ctrl+o to expand)" footer. | |
| 57 | + | ExpandAll bool | |
| 51 | 58 | } | |
| 52 | 59 | ||
| 60 | + | // ToolCollapsePreview is the number of lines shown before a long tool | |
| 61 | + | // result is replaced with a "... ctrl+o to expand" footer. Tool | |
| 62 | + | // results shorter than ToolCollapseLines always render in full. | |
| 63 | + | const ( | |
| 64 | + | ToolCollapsePreview = 10 | |
| 65 | + | ToolCollapseLines = 12 | |
| 66 | + | ) | |
| 67 | + | ||
| 53 | 68 | // ToolCallView is a pending tool invocation plus optional result. | |
| 54 | 69 | type ToolCallView struct { | |
| 55 | 70 | ID string | |
| @@ -169,7 +184,15 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 169 | 184 | if tc.Error { | |
| 170 | 185 | color = v.Theme.Error | |
| 171 | 186 | } | |
| 172 | - | lines = append(lines, toolResultBlock(v.Theme, tc.Result, width, color)...) | |
| 187 | + | block := toolResultBlock(v.Theme, tc.Result, width, color) | |
| 188 | + | // Strip rules, collapse the body, put rules back on. | |
| 189 | + | if len(block) >= 2 { | |
| 190 | + | top, bot := block[0], block[len(block)-1] | |
| 191 | + | body := v.collapseToolBody(block[1:len(block)-1], false) | |
| 192 | + | block = append([]string{top}, body...) | |
| 193 | + | block = append(block, bot) | |
| 194 | + | } | |
| 195 | + | lines = append(lines, block...) | |
| 173 | 196 | } | |
| 174 | 197 | return lines | |
| 175 | 198 | } | |
| @@ -184,20 +207,45 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||
| 184 | 207 | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string { | |
| 185 | 208 | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | |
| 186 | 209 | ||
| 187 | - | var out []string | |
| 188 | - | out = append(out, rule) | |
| 210 | + | var body []string | |
| 211 | + | hasImage := false | |
| 189 | 212 | for _, b := range blocks { | |
| 190 | 213 | switch bb := b.(type) { | |
| 191 | 214 | case provider.TextBlock: | |
| 192 | - | out = append(out, v.renderToolText(bb.Text, width, color, sourcePath)...) | |
| 215 | + | body = append(body, v.renderToolText(bb.Text, width, color, sourcePath)...) | |
| 193 | 216 | case provider.ImageBlock: | |
| 194 | - | out = append(out, v.renderImageBlock(bb, width)...) | |
| 217 | + | hasImage = true | |
| 218 | + | body = append(body, v.renderImageBlock(bb, width)...) | |
| 195 | 219 | } | |
| 196 | 220 | } | |
| 221 | + | body = v.collapseToolBody(body, hasImage) | |
| 222 | + | ||
| 223 | + | out := make([]string, 0, len(body)+2) | |
| 224 | + | out = append(out, rule) | |
| 225 | + | out = append(out, body...) | |
| 197 | 226 | out = append(out, rule) | |
| 198 | 227 | return out | |
| 199 | 228 | } | |
| 200 | 229 | ||
| 230 | + | // collapseToolBody trims lines to the configured preview size when the | |
| 231 | + | // view is not in ExpandAll mode, appending a muted "... ctrl+o to | |
| 232 | + | // expand" footer. Image blocks never collapse — they're short in text | |
| 233 | + | // rows but represent real content the user wants to see. | |
| 234 | + | func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |
| 235 | + | if v.ExpandAll || hasImage { | |
| 236 | + | return lines | |
| 237 | + | } | |
| 238 | + | if len(lines) <= ToolCollapseLines { | |
| 239 | + | return lines | |
| 240 | + | } | |
| 241 | + | kept := lines[:ToolCollapsePreview] | |
| 242 | + | hidden := len(lines) - ToolCollapsePreview | |
| 243 | + | total := len(lines) | |
| 244 | + | footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total) | |
| 245 | + | footer = v.Theme.FG256(v.Theme.Muted, footer) | |
| 246 | + | return append(append([]string(nil), kept...), footer) | |
| 247 | + | } | |
| 248 | + | ||
| 201 | 249 | // renderToolText renders a text block inside a tool result. If the | |
| 202 | 250 | // text contains a unified-diff section (lines starting with "--- " / | |
| 203 | 251 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | |
| @@ -217,6 +265,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||
| 217 | 265 | lines := strings.Split(text, "\n") | |
| 218 | 266 | ||
| 219 | 267 | inDiff := false | |
| 268 | + | oldLine, newLine := 1, 1 | |
| 220 | 269 | var out []string | |
| 221 | 270 | for _, l := range lines { | |
| 222 | 271 | // Detect diff header: "--- name" followed somewhere by "+++ name". | |
| @@ -225,16 +274,29 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||
| 225 | 274 | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | |
| 226 | 275 | continue | |
| 227 | 276 | } | |
| 277 | + | // Hunk header "@@ -a,b +c,d @@" resets the counters so patches | |
| 278 | + | // that skip around in the file still get correct numbering. | |
| 279 | + | if inDiff && strings.HasPrefix(l, "@@") { | |
| 280 | + | if o, n, ok := parseHunkHeader(l); ok { | |
| 281 | + | oldLine, newLine = o, n | |
| 282 | + | } | |
| 283 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | |
| 284 | + | continue | |
| 285 | + | } | |
| 228 | 286 | if inDiff && len(l) > 0 { | |
| 229 | 287 | switch l[0] { | |
| 230 | 288 | case '+': | |
| 231 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool)) | |
| 289 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)) | |
| 290 | + | newLine++ | |
| 232 | 291 | continue | |
| 233 | 292 | case '-': | |
| 234 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Error)) | |
| 293 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)) | |
| 294 | + | oldLine++ | |
| 235 | 295 | continue | |
| 236 | 296 | case ' ': | |
| 237 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted)) | |
| 297 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)) | |
| 298 | + | oldLine++ | |
| 299 | + | newLine++ | |
| 238 | 300 | continue | |
| 239 | 301 | } | |
| 240 | 302 | } | |
| @@ -246,16 +308,94 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||
| 246 | 308 | return out | |
| 247 | 309 | } | |
| 248 | 310 | ||
| 249 | - | // renderDiffRow renders a single unified-diff line in fg color only. | |
| 250 | - | // The leading +/-/space stays visible so the user can tell at a glance | |
| 251 | - | // what changed; the rest of the line is colored the same. Long lines | |
| 252 | - | // are wrapped with a 4-cell indent preserved. | |
| 253 | - | func (v *View) renderDiffRow(line string, width, color int) string { | |
| 254 | - | body := line | |
| 255 | - | if len(body) > width-4 { | |
| 256 | - | body = body[:width-7] + "…" | |
| 311 | + | // parseHunkHeader extracts the starting old/new line from a unified | |
| 312 | + | // diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the | |
| 313 | + | // header is malformed or missing numbers. | |
| 314 | + | func parseHunkHeader(l string) (oldStart, newStart int, ok bool) { | |
| 315 | + | // Skip "@@ " | |
| 316 | + | rest := strings.TrimPrefix(l, "@@") | |
| 317 | + | rest = strings.TrimSpace(rest) | |
| 318 | + | if !strings.HasPrefix(rest, "-") { | |
| 319 | + | return 0, 0, false | |
| 320 | + | } | |
| 321 | + | rest = rest[1:] | |
| 322 | + | space := strings.IndexByte(rest, ' ') | |
| 323 | + | if space < 0 { | |
| 324 | + | return 0, 0, false | |
| 325 | + | } | |
| 326 | + | oldPart := rest[:space] | |
| 327 | + | rest = strings.TrimSpace(rest[space+1:]) | |
| 328 | + | if !strings.HasPrefix(rest, "+") { | |
| 329 | + | return 0, 0, false | |
| 330 | + | } | |
| 331 | + | rest = rest[1:] | |
| 332 | + | if sp := strings.IndexAny(rest, " \t"); sp >= 0 { | |
| 333 | + | rest = rest[:sp] | |
| 334 | + | } | |
| 335 | + | parseStart := func(s string) (int, bool) { | |
| 336 | + | if c := strings.IndexByte(s, ','); c >= 0 { | |
| 337 | + | s = s[:c] | |
| 338 | + | } | |
| 339 | + | n, err := strconv.Atoi(s) | |
| 340 | + | if err != nil || n < 1 { | |
| 341 | + | return 0, false | |
| 342 | + | } | |
| 343 | + | return n, true | |
| 344 | + | } | |
| 345 | + | o, ok1 := parseStart(oldPart) | |
| 346 | + | n, ok2 := parseStart(rest) | |
| 347 | + | if !ok1 || !ok2 { | |
| 348 | + | return 0, 0, false | |
| 349 | + | } | |
| 350 | + | return o, n, true | |
| 351 | + | } | |
| 352 | + | ||
| 353 | + | // renderDiffRow renders one unified-diff line with a read-style gutter | |
| 354 | + | // (6-cell right-aligned line number, muted) followed by the +/-/space | |
| 355 | + | // marker and the code. Code is syntax-highlighted if sourcePath hints | |
| 356 | + | // at a known language; falls back to the plain diff color otherwise. | |
| 357 | + | func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string { | |
| 358 | + | if len(line) == 0 { | |
| 359 | + | return "" | |
| 360 | + | } | |
| 361 | + | code := line[1:] // strip the leading marker; we re-emit it in colour | |
| 362 | + | ||
| 363 | + | // Syntax-highlight the code half when we know the language. Use | |
| 364 | + | // the same HighlightCode pipeline as renderNumberedFile so the | |
| 365 | + | // palette matches. | |
| 366 | + | lang := LanguageFromPath(sourcePath) | |
| 367 | + | var codeRendered string | |
| 368 | + | if lang != "" { | |
| 369 | + | if h := HighlightCode(code, lang); len(h) == 1 { | |
| 370 | + | codeRendered = h[0] | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | if codeRendered == "" { | |
| 374 | + | codeRendered = v.Theme.FG256(color, code) | |
| 375 | + | } | |
| 376 | + | ||
| 377 | + | gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo)) | |
| 378 | + | marker := v.Theme.FG256(color, string(mark)+" ") | |
| 379 | + | row := " " + gutter + marker + codeRendered | |
| 380 | + | ||
| 381 | + | // Cheap width clamp: truncate visible text if the raw code is too | |
| 382 | + | // long. We work on the pre-ANSI code string because measuring ansi | |
| 383 | + | // output is unreliable. | |
| 384 | + | maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */ | |
| 385 | + | if maxCode > 0 && len(code) > maxCode { | |
| 386 | + | trunc := code[:maxCode-1] + "…" | |
| 387 | + | if lang != "" { | |
| 388 | + | if h := HighlightCode(trunc, lang); len(h) == 1 { | |
| 389 | + | codeRendered = h[0] | |
| 390 | + | } else { | |
| 391 | + | codeRendered = v.Theme.FG256(color, trunc) | |
| 392 | + | } | |
| 393 | + | } else { | |
| 394 | + | codeRendered = v.Theme.FG256(color, trunc) | |
| 395 | + | } | |
| 396 | + | row = " " + gutter + marker + codeRendered | |
| 257 | 397 | } | |
| 258 | - | return " " + v.Theme.FG256(color, body) | |
| 398 | + | return row | |
| 259 | 399 | } | |
| 260 | 400 | ||
| 261 | 401 | // renderImageBlock returns the lines for one image, inline if possible. | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "regexp" | 7 | "regexp" | ||
| 8 | + | "strconv" | |||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| 10 | "github.com/patriceckhart/zot/internal/provider" | 11 | "github.com/patriceckhart/zot/internal/provider" | ||
| @@ -48,8 +49,22 @@ type View struct { | |||||
| 48 | ToolCalls []ToolCallView // tool calls in flight or completed | 49 | ToolCalls []ToolCallView // tool calls in flight or completed | ||
| 49 | StatusLine string | 50 | StatusLine string | ||
| 50 | Err string | 51 | Err string | ||
| 52 | + | ||||
| 53 | + | // ExpandAll forces every long tool result to render in full. | |||
| 54 | + | // Toggled from the tui by ctrl+o. When false, results longer than | |||
| 55 | + | // ToolCollapseLines collapse to ToolCollapsePreview lines plus a | |||
| 56 | + | // "... (N more lines, M total, ctrl+o to expand)" footer. | |||
| 57 | + | ExpandAll bool | |||
| 51 | } | 58 | } | ||
| 52 | 59 | ||||
| 60 | + | // ToolCollapsePreview is the number of lines shown before a long tool | |||
| 61 | + | // result is replaced with a "... ctrl+o to expand" footer. Tool | |||
| 62 | + | // results shorter than ToolCollapseLines always render in full. | |||
| 63 | + | const ( | |||
| 64 | + | ToolCollapsePreview = 10 | |||
| 65 | + | ToolCollapseLines = 12 | |||
| 66 | + | ) | |||
| 67 | + | ||||
| 53 | // ToolCallView is a pending tool invocation plus optional result. | 68 | // ToolCallView is a pending tool invocation plus optional result. | ||
| 54 | type ToolCallView struct { | 69 | type ToolCallView struct { | ||
| 55 | ID string | 70 | ID string | ||
| @@ -169,7 +184,15 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 169 | if tc.Error { | 184 | if tc.Error { | ||
| 170 | color = v.Theme.Error | 185 | color = v.Theme.Error | ||
| 171 | } | 186 | } | ||
| 172 | - | lines = append(lines, toolResultBlock(v.Theme, tc.Result, width, color)...) | 187 | + | block := toolResultBlock(v.Theme, tc.Result, width, color) |
| 188 | + | // Strip rules, collapse the body, put rules back on. | |||
| 189 | + | if len(block) >= 2 { | |||
| 190 | + | top, bot := block[0], block[len(block)-1] | |||
| 191 | + | body := v.collapseToolBody(block[1:len(block)-1], false) | |||
| 192 | + | block = append([]string{top}, body...) | |||
| 193 | + | block = append(block, bot) | |||
| 194 | + | } | |||
| 195 | + | lines = append(lines, block...) | |||
| 173 | } | 196 | } | ||
| 174 | return lines | 197 | return lines | ||
| 175 | } | 198 | } | ||
| @@ -184,20 +207,45 @@ func (v *View) renderToolCall(tc ToolCallView, width int) []string { | |||||
| 184 | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string { | 207 | func (v *View) renderToolResultContent(blocks []provider.Content, width, color int, sourcePath string) []string { | ||
| 185 | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | 208 | rule := v.Theme.FG256(v.Theme.Muted, strings.Repeat("─", width)) | ||
| 186 | 209 | ||||
| 187 | - | var out []string | 210 | + | var body []string |
| 188 | - | out = append(out, rule) | 211 | + | hasImage := false |
| 189 | for _, b := range blocks { | 212 | for _, b := range blocks { | ||
| 190 | switch bb := b.(type) { | 213 | switch bb := b.(type) { | ||
| 191 | case provider.TextBlock: | 214 | case provider.TextBlock: | ||
| 192 | - | out = append(out, v.renderToolText(bb.Text, width, color, sourcePath)...) | 215 | + | body = append(body, v.renderToolText(bb.Text, width, color, sourcePath)...) |
| 193 | case provider.ImageBlock: | 216 | case provider.ImageBlock: | ||
| 194 | - | out = append(out, v.renderImageBlock(bb, width)...) | 217 | + | hasImage = true |
| 218 | + | body = append(body, v.renderImageBlock(bb, width)...) | |||
| 195 | } | 219 | } | ||
| 196 | } | 220 | } | ||
| 221 | + | body = v.collapseToolBody(body, hasImage) | |||
| 222 | + | ||||
| 223 | + | out := make([]string, 0, len(body)+2) | |||
| 224 | + | out = append(out, rule) | |||
| 225 | + | out = append(out, body...) | |||
| 197 | out = append(out, rule) | 226 | out = append(out, rule) | ||
| 198 | return out | 227 | return out | ||
| 199 | } | 228 | } | ||
| 200 | 229 | ||||
| 230 | + | // collapseToolBody trims lines to the configured preview size when the | |||
| 231 | + | // view is not in ExpandAll mode, appending a muted "... ctrl+o to | |||
| 232 | + | // expand" footer. Image blocks never collapse — they're short in text | |||
| 233 | + | // rows but represent real content the user wants to see. | |||
| 234 | + | func (v *View) collapseToolBody(lines []string, hasImage bool) []string { | |||
| 235 | + | if v.ExpandAll || hasImage { | |||
| 236 | + | return lines | |||
| 237 | + | } | |||
| 238 | + | if len(lines) <= ToolCollapseLines { | |||
| 239 | + | return lines | |||
| 240 | + | } | |||
| 241 | + | kept := lines[:ToolCollapsePreview] | |||
| 242 | + | hidden := len(lines) - ToolCollapsePreview | |||
| 243 | + | total := len(lines) | |||
| 244 | + | footer := fmt.Sprintf(" ... (%d more lines, %d total, ctrl+o to expand)", hidden, total) | |||
| 245 | + | footer = v.Theme.FG256(v.Theme.Muted, footer) | |||
| 246 | + | return append(append([]string(nil), kept...), footer) | |||
| 247 | + | } | |||
| 248 | + | ||||
| 201 | // renderToolText renders a text block inside a tool result. If the | 249 | // renderToolText renders a text block inside a tool result. If the | ||
| 202 | // text contains a unified-diff section (lines starting with "--- " / | 250 | // text contains a unified-diff section (lines starting with "--- " / | ||
| 203 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | 251 | // "+++ " / "+" / "-"/" "), those rows are styled with add/remove | ||
| @@ -217,6 +265,7 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||||
| 217 | lines := strings.Split(text, "\n") | 265 | lines := strings.Split(text, "\n") | ||
| 218 | 266 | ||||
| 219 | inDiff := false | 267 | inDiff := false | ||
| 268 | + | oldLine, newLine := 1, 1 | |||
| 220 | var out []string | 269 | var out []string | ||
| 221 | for _, l := range lines { | 270 | for _, l := range lines { | ||
| 222 | // Detect diff header: "--- name" followed somewhere by "+++ name". | 271 | // Detect diff header: "--- name" followed somewhere by "+++ name". | ||
| @@ -225,16 +274,29 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||||
| 225 | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | 274 | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | ||
| 226 | continue | 275 | continue | ||
| 227 | } | 276 | } | ||
| 277 | + | // Hunk header "@@ -a,b +c,d @@" resets the counters so patches | |||
| 278 | + | // that skip around in the file still get correct numbering. | |||
| 279 | + | if inDiff && strings.HasPrefix(l, "@@") { | |||
| 280 | + | if o, n, ok := parseHunkHeader(l); ok { | |||
| 281 | + | oldLine, newLine = o, n | |||
| 282 | + | } | |||
| 283 | + | out = append(out, " "+v.Theme.FG256(v.Theme.Muted, l)) | |||
| 284 | + | continue | |||
| 285 | + | } | |||
| 228 | if inDiff && len(l) > 0 { | 286 | if inDiff && len(l) > 0 { | ||
| 229 | switch l[0] { | 287 | switch l[0] { | ||
| 230 | case '+': | 288 | case '+': | ||
| 231 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool)) | 289 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Tool, newLine, '+', sourcePath)) |
| 290 | + | newLine++ | |||
| 232 | continue | 291 | continue | ||
| 233 | case '-': | 292 | case '-': | ||
| 234 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Error)) | 293 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Error, oldLine, '-', sourcePath)) |
| 294 | + | oldLine++ | |||
| 235 | continue | 295 | continue | ||
| 236 | case ' ': | 296 | case ' ': | ||
| 237 | - | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted)) | 297 | + | out = append(out, v.renderDiffRow(l, width, v.Theme.Muted, newLine, ' ', sourcePath)) |
| 298 | + | oldLine++ | |||
| 299 | + | newLine++ | |||
| 238 | continue | 300 | continue | ||
| 239 | } | 301 | } | ||
| 240 | } | 302 | } | ||
| @@ -246,16 +308,94 @@ func (v *View) renderToolText(text string, width, defaultColor int, sourcePath s | |||||
| 246 | return out | 308 | return out | ||
| 247 | } | 309 | } | ||
| 248 | 310 | ||||
| 249 | - | // renderDiffRow renders a single unified-diff line in fg color only. | 311 | + | // parseHunkHeader extracts the starting old/new line from a unified |
| 250 | - | // The leading +/-/space stays visible so the user can tell at a glance | 312 | + | // diff hunk header ("@@ -12,5 +12,7 @@ ..."). Returns ok=false if the |
| 251 | - | // what changed; the rest of the line is colored the same. Long lines | 313 | + | // header is malformed or missing numbers. |
| 252 | - | // are wrapped with a 4-cell indent preserved. | 314 | + | func parseHunkHeader(l string) (oldStart, newStart int, ok bool) { |
| 253 | - | func (v *View) renderDiffRow(line string, width, color int) string { | 315 | + | // Skip "@@ " |
| 254 | - | body := line | 316 | + | rest := strings.TrimPrefix(l, "@@") |
| 255 | - | if len(body) > width-4 { | 317 | + | rest = strings.TrimSpace(rest) |
| 256 | - | body = body[:width-7] + "…" | 318 | + | if !strings.HasPrefix(rest, "-") { |
| 319 | + | return 0, 0, false | |||
| 320 | + | } | |||
| 321 | + | rest = rest[1:] | |||
| 322 | + | space := strings.IndexByte(rest, ' ') | |||
| 323 | + | if space < 0 { | |||
| 324 | + | return 0, 0, false | |||
| 325 | + | } | |||
| 326 | + | oldPart := rest[:space] | |||
| 327 | + | rest = strings.TrimSpace(rest[space+1:]) | |||
| 328 | + | if !strings.HasPrefix(rest, "+") { | |||
| 329 | + | return 0, 0, false | |||
| 330 | + | } | |||
| 331 | + | rest = rest[1:] | |||
| 332 | + | if sp := strings.IndexAny(rest, " \t"); sp >= 0 { | |||
| 333 | + | rest = rest[:sp] | |||
| 334 | + | } | |||
| 335 | + | parseStart := func(s string) (int, bool) { | |||
| 336 | + | if c := strings.IndexByte(s, ','); c >= 0 { | |||
| 337 | + | s = s[:c] | |||
| 338 | + | } | |||
| 339 | + | n, err := strconv.Atoi(s) | |||
| 340 | + | if err != nil || n < 1 { | |||
| 341 | + | return 0, false | |||
| 342 | + | } | |||
| 343 | + | return n, true | |||
| 344 | + | } | |||
| 345 | + | o, ok1 := parseStart(oldPart) | |||
| 346 | + | n, ok2 := parseStart(rest) | |||
| 347 | + | if !ok1 || !ok2 { | |||
| 348 | + | return 0, 0, false | |||
| 349 | + | } | |||
| 350 | + | return o, n, true | |||
| 351 | + | } | |||
| 352 | + | ||||
| 353 | + | // renderDiffRow renders one unified-diff line with a read-style gutter | |||
| 354 | + | // (6-cell right-aligned line number, muted) followed by the +/-/space | |||
| 355 | + | // marker and the code. Code is syntax-highlighted if sourcePath hints | |||
| 356 | + | // at a known language; falls back to the plain diff color otherwise. | |||
| 357 | + | func (v *View) renderDiffRow(line string, width, color int, lineNo int, mark byte, sourcePath string) string { | |||
| 358 | + | if len(line) == 0 { | |||
| 359 | + | return "" | |||
| 360 | + | } | |||
| 361 | + | code := line[1:] // strip the leading marker; we re-emit it in colour | |||
| 362 | + | ||||
| 363 | + | // Syntax-highlight the code half when we know the language. Use | |||
| 364 | + | // the same HighlightCode pipeline as renderNumberedFile so the | |||
| 365 | + | // palette matches. | |||
| 366 | + | lang := LanguageFromPath(sourcePath) | |||
| 367 | + | var codeRendered string | |||
| 368 | + | if lang != "" { | |||
| 369 | + | if h := HighlightCode(code, lang); len(h) == 1 { | |||
| 370 | + | codeRendered = h[0] | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | if codeRendered == "" { | |||
| 374 | + | codeRendered = v.Theme.FG256(color, code) | |||
| 375 | + | } | |||
| 376 | + | ||||
| 377 | + | gutter := v.Theme.FG256(v.Theme.Muted, fmt.Sprintf("%6d\t", lineNo)) | |||
| 378 | + | marker := v.Theme.FG256(color, string(mark)+" ") | |||
| 379 | + | row := " " + gutter + marker + codeRendered | |||
| 380 | + | ||||
| 381 | + | // Cheap width clamp: truncate visible text if the raw code is too | |||
| 382 | + | // long. We work on the pre-ANSI code string because measuring ansi | |||
| 383 | + | // output is unreliable. | |||
| 384 | + | maxCode := width - 4 /* indent */ - 7 /* gutter */ - 2 /* marker */ | |||
| 385 | + | if maxCode > 0 && len(code) > maxCode { | |||
| 386 | + | trunc := code[:maxCode-1] + "…" | |||
| 387 | + | if lang != "" { | |||
| 388 | + | if h := HighlightCode(trunc, lang); len(h) == 1 { | |||
| 389 | + | codeRendered = h[0] | |||
| 390 | + | } else { | |||
| 391 | + | codeRendered = v.Theme.FG256(color, trunc) | |||
| 392 | + | } | |||
| 393 | + | } else { | |||
| 394 | + | codeRendered = v.Theme.FG256(color, trunc) | |||
| 395 | + | } | |||
| 396 | + | row = " " + gutter + marker + codeRendered | |||
| 257 | } | 397 | } | ||
| 258 | - | return " " + v.Theme.FG256(color, body) | 398 | + | return row |
| 259 | } | 399 | } | ||
| 260 | 400 | ||||
| 261 | // renderImageBlock returns the lines for one image, inline if possible. | 401 | // renderImageBlock returns the lines for one image, inline if possible. | ||