|
1 | 1 | <script lang="ts"> |
2 | | - import { Send, Square, Paperclip, FastForward, ShieldCheck, CircleStop, Mic, LoaderCircle, GitBranch, SquareTerminal } from 'lucide-svelte'; |
| 2 | + import { Send, Square, Paperclip, FastForward, ShieldCheck, CircleStop, Mic, LoaderCircle, GitBranch, Brain } from 'lucide-svelte'; |
3 | 3 | import { message } from '@tauri-apps/plugin-dialog'; |
4 | 4 | import IconButton from '$lib/ui/IconButton.svelte'; |
5 | 5 | import BackendIcon from '$lib/BackendIcon.svelte'; |
|
26 | 26 | el = $bindable(), |
27 | 27 | pickerQuery = $bindable(''), |
28 | 28 | pickerSelIdx = $bindable(0), |
29 | | - pickerKeyNav = $bindable(false), |
30 | 29 | modelRows = [], |
31 | 30 | modelTitle = '', |
32 | 31 | modelSearch = false, |
33 | 32 | backendLocked = true, |
34 | 33 | gitBranch = '', |
35 | | - tuiReady = false, |
36 | | - onOpenTui, |
37 | 34 | onBackend, |
38 | 35 | onSubmit, |
39 | 36 | onStop, |
|
42 | 39 | onModel, |
43 | 40 | onModelSelect, |
44 | 41 | onModelClose, |
| 42 | + onEffort, |
| 43 | + effortDisabled = false, |
45 | 44 | onApproval |
46 | 45 | }: { |
47 | 46 | chat: ChatState; |
|
51 | 50 | el: HTMLElement | null; |
52 | 51 | pickerQuery?: string; |
53 | 52 | pickerSelIdx?: number; |
54 | | - /** Arrow keys moved the picker selection (effort chips follow it then). */ |
55 | | - pickerKeyNav?: boolean; |
56 | 53 | modelRows?: ModelRow[]; |
57 | 54 | modelTitle?: string; |
58 | 55 | modelSearch?: boolean; |
|
61 | 58 | backendLocked?: boolean; |
62 | 59 | /** Current git branch for the footer strip ('' hides the chip). */ |
63 | 60 | gitBranch?: string; |
64 | | - /** The session holds a resumable engine conversation the native TUI |
65 | | - * can continue (gates the "continue in TUI" chip). */ |
66 | | - tuiReady?: boolean; |
67 | | - /** Hand the conversation to the native TUI. Absent (e.g. ACP) hides |
68 | | - * the chip entirely. */ |
69 | | - onOpenTui?: () => void; |
70 | 61 | onBackend?: (b: BackendId, acpAgent?: { id: string; name: string }) => void | Promise<void>; |
71 | 62 | onSubmit: () => void; |
72 | 63 | onStop: () => void; |
|
75 | 66 | onModel: () => void; |
76 | 67 | onModelSelect?: (command: string) => void; |
77 | 68 | onModelClose?: () => void; |
| 69 | + onEffort: (effort: string) => void; |
| 70 | + effortDisabled?: boolean; |
78 | 71 | onApproval: (mode: ApprovalMode) => void; |
79 | 72 | } = $props(); |
80 | 73 |
|
81 | 74 | let slashIdx = $state(0); |
| 75 | + let showEffort = $state(false); |
82 | 76 | let showApproval = $state(false); |
83 | 77 |
|
84 | 78 | // The model popover holds its own open flag so it can outlive an agent |
85 | 79 | // switch (the new session ChatState starts with no picker) and open for |
86 | 80 | // agents without a model catalog (ACP) — the rail inside it is the only |
87 | 81 | // way to pick a coding agent. |
88 | 82 | let modelOpen = $state(false); |
89 | | - const modelPopoverVisible = $derived(modelOpen || chat.picker?.kind === 'model'); |
| 83 | + const modelPopoverVisible = $derived(modelOpen); |
90 | 84 | function toggleModelPopover() { |
91 | 85 | if (modelPopoverVisible) { |
92 | 86 | closeModelPopover(); |
93 | 87 | return; |
94 | 88 | } |
| 89 | + showEffort = false; |
95 | 90 | modelOpen = true; |
96 | | - pickerKeyNav = false; |
97 | 91 | if (bcaps.modelPicker) onModel(); |
98 | 92 | } |
99 | 93 | function closeModelPopover() { |
|
104 | 98 | // (ACP agents — `modelOpen` is ours, not chat.picker). Capture phase so the |
105 | 99 | // key never reaches the pane's window handler or the editor. |
106 | 100 | function onWindowKeyCapture(e: KeyboardEvent) { |
107 | | - if (e.key === 'Escape' && modelPopoverVisible) { |
| 101 | + if (e.key === 'Escape' && (modelPopoverVisible || showEffort)) { |
108 | 102 | e.preventDefault(); |
109 | 103 | e.stopPropagation(); |
110 | | - closeModelPopover(); |
| 104 | + if (modelPopoverVisible) closeModelPopover(); |
| 105 | + showEffort = false; |
111 | 106 | } |
112 | 107 | } |
113 | 108 | function selectFromPopover(command: string) { |
114 | 109 | modelOpen = false; |
115 | 110 | onModelSelect?.(command); |
116 | 111 | } |
| 112 | + function setEffort(effort: string) { |
| 113 | + if (effortDisabled) return; |
| 114 | + onEffort(effort); |
| 115 | + showEffort = false; |
| 116 | + } |
117 | 117 |
|
118 | 118 | // The fallback label on the model button before the engine reports a model: |
119 | 119 | // the ACP agent's registered name, else the engine's brand name. |
|
254 | 254 | ] |
255 | 255 | ); |
256 | 256 | const approvalLabel = $derived(APPROVAL.find((a) => a.value === chat.approvalMode)?.label ?? t('chat.approvalAsk')); |
| 257 | + const effortOptions = $derived(chat.efforts.map((effort) => ({ value: effort }))); |
257 | 258 | // Persisting + pushing the mode to the engine lives with the page (it owns |
258 | 259 | // the session id); the picker only reports the choice. |
259 | 260 | function setApproval(m: string) { |
|
549 | 550 | {backendLocked} |
550 | 551 | bind:query={pickerQuery} |
551 | 552 | bind:selIdx={pickerSelIdx} |
552 | | - bind:keyNav={pickerKeyNav} |
553 | 553 | onClose={closeModelPopover} |
554 | 554 | onSelect={selectFromPopover} |
555 | 555 | {onBackend} |
|
560 | 560 | {:else if chat.model} |
561 | 561 | <span class="flatbtn model static"><BackendIcon backend={chat.backendId} size={15} /><span>{chat.modelLabel || chat.model}</span></span> |
562 | 562 | {/if} |
| 563 | + {#if chat.efforts.length} |
| 564 | + <div class="effortsel"> |
| 565 | + <button |
| 566 | + class="flatbtn effort" |
| 567 | + disabled={effortDisabled} |
| 568 | + class:pending={effortDisabled} |
| 569 | + onclick={() => { |
| 570 | + if (modelPopoverVisible) closeModelPopover(); |
| 571 | + showEffort = !showEffort; |
| 572 | + }} |
| 573 | + title={t('chat.effortTitle')} |
| 574 | + aria-label={t('chat.effortTitle')} |
| 575 | + > |
| 576 | + <Brain size={15} /><span>{chat.effort || t('chat.effortTitle')}</span> |
| 577 | + </button> |
| 578 | + {#if showEffort} |
| 579 | + <button class="pop-backdrop" aria-label="close" onclick={() => (showEffort = false)}></button> |
| 580 | + <div class="effort-pop"> |
| 581 | + <Segmented value={chat.effort} options={effortOptions} onChange={setEffort} /> |
| 582 | + </div> |
| 583 | + {/if} |
| 584 | + </div> |
| 585 | + {/if} |
563 | 586 | <div class="cspace"></div> |
564 | 587 | <button |
565 | 588 | class="cact voice" |
|
596 | 619 | {/if} |
597 | 620 | </div> |
598 | 621 | {/if} |
599 | | - {#if onOpenTui && tuiReady} |
600 | | - <button |
601 | | - class="foot-chip" |
602 | | - onclick={onOpenTui} |
603 | | - title={t('chat.tuiContinueTitle')} |
604 | | - > |
605 | | - <SquareTerminal size={12} /><span>{t('chat.tuiContinue')}</span> |
606 | | - </button> |
607 | | - {/if} |
608 | 622 | <div class="fspace"></div> |
609 | 623 | {#if bcaps.contextUsage && ctxLimit > 0} |
610 | 624 | <div class="foot-ctx"> |
|
705 | 719 | .flatbtn.model span { |
706 | 720 | font-family: var(--font-mono); |
707 | 721 | font-size: 12px; |
| 722 | + min-width: 0; |
708 | 723 | max-width: 220px; |
709 | 724 | white-space: nowrap; |
710 | 725 | overflow: hidden; |
711 | 726 | text-overflow: ellipsis; |
712 | 727 | } |
| 728 | + .flatbtn.effort span { |
| 729 | + font-family: var(--font-mono); |
| 730 | + font-size: 12px; |
| 731 | + } |
713 | 732 | /* read-only model label for backends without an in-chat model picker */ |
714 | 733 | .flatbtn.static { |
715 | 734 | cursor: default; |
|
720 | 739 | .flatbtn.static:active { |
721 | 740 | transform: none; |
722 | 741 | } |
723 | | - /* position:relative anchors for the model popover / the approval popover */ |
| 742 | + /* position:relative anchors for the compact composer popovers */ |
724 | 743 | .modelsel, |
| 744 | + .effortsel, |
725 | 745 | .footsel { |
726 | 746 | position: relative; |
727 | 747 | display: inline-flex; |
728 | 748 | } |
| 749 | + .modelsel, |
| 750 | + .flatbtn.model { |
| 751 | + min-width: 0; |
| 752 | + } |
| 753 | + .effortsel { |
| 754 | + flex-shrink: 0; |
| 755 | + } |
729 | 756 | .pop-backdrop { |
730 | 757 | position: fixed; |
731 | 758 | inset: 0; |
|
735 | 762 | cursor: default; |
736 | 763 | } |
737 | 764 | .effort-pop { |
738 | | - position: absolute; |
739 | | - bottom: calc(100% + 8px); |
740 | | - left: 0; |
| 765 | + position: fixed; |
| 766 | + bottom: 72px; |
| 767 | + left: 18px; |
| 768 | + max-width: calc(100vw - 36px); |
741 | 769 | z-index: 21; |
742 | 770 | padding: 6px; |
743 | 771 | background: var(--panel); |
|
747 | 775 | transform-origin: bottom left; |
748 | 776 | animation: pop-in var(--t-med) var(--ease-spring); |
749 | 777 | } |
| 778 | + .effort-pop :global(.seg) { |
| 779 | + max-width: 100%; |
| 780 | + overflow-x: auto; |
| 781 | + } |
750 | 782 | .cspace { |
751 | 783 | flex: 1; |
752 | 784 | } |
|
0 commit comments