FazBrowse GitHub Viewer
|
Trending
|
URL:
|
Home
Tools:
[Download Repo ZIP]
[View Raw Code]
[Original HTTPS Page]
ClaudeCodeExtension/UI/ChatTranscriptView.xaml.cs at master · dliedke/ClaudeCodeExtension · GitHub
dliedke
/
ClaudeCodeExtension
Public
Notifications
You must be signed in to change notification settings
Fork
21
Star
70
Code
Issues
0
Pull requests
0
Actions
Projects
Security and quality
0
Insights
Additional navigation options
Code
Issues
Pull requests
Actions
Projects
Security and quality
Insights
Expand file tree
Breadcrumbs
ClaudeCodeExtension
/
UI
/
ChatTranscriptView.xaml.cs
Copy path
More file actions
More file actions
Latest commit
History
History
History
1848 lines (1579 loc) · 76.2 KB
Breadcrumbs
ClaudeCodeExtension
/
UI
/
ChatTranscriptView.xaml.cs
Copy path
File metadata and controls
1848 lines (1579 loc) · 76.2 KB
Raw
Copy raw file
Download raw file
Open symbols panel
Edit and raw actions
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/* *******************************************************************************************************************
* Application: ClaudeCodeExtension
*
* Autor: Daniel Carvalho Liedke / Claude Code
*
* Copyright © Daniel Carvalho Liedke 2026
* Usage and reproduction in any manner whatsoever without the written permission of Daniel Carvalho Liedke is strictly forbidden.
*
* Purpose: Chat transcript displayed in place of the embedded terminal when native mode is on
*
* *******************************************************************************************************************/
using
System
;
using
System
.
Collections
.
Generic
;
using
System
.
Collections
.
ObjectModel
;
using
System
.
Collections
.
Specialized
;
using
System
.
ComponentModel
;
using
System
.
Globalization
;
using
System
.
Windows
;
using
System
.
Windows
.
Controls
;
using
System
.
Windows
.
Controls
.
Primitives
;
using
System
.
Windows
.
Data
;
using
System
.
Windows
.
Media
;
using
Microsoft
.
VisualStudio
.
PlatformUI
;
using
Microsoft
.
VisualStudio
.
Shell
;
namespace
ClaudeCodeVS
.
UI
{
/// <summary>
/// The conversation view for native mode. Owns only presentation: the parent control feeds it
/// messages and it never talks to an agent session directly.
/// </summary>
public
partial
class
ChatTranscriptView
:
UserControl
{
// 2 device-independent pixels of slack: the scroll offset rarely lands exactly on the extent,
// and requiring equality would leave auto-scroll permanently off.
private
const
double
BottomTolerance
=
2.0
;
private
const
double
MinZoom
=
0.6
;
private
const
double
MaxZoom
=
2.5
;
private
const
double
ZoomStep
=
0.1
;
private
const
double
MinComposerHeight
=
46
;
private
const
double
MaxComposerHeight
=
400
;
private
ChatMessageViewModel
_trackedMessage
;
private
bool
_autoScroll
=
true
;
private
bool
_suppressEffortEvent
;
private
int
_effortIndexOnOpen
;
private
int
_effortIndex
;
private
string
[
]
_effortStopLabels
;
private
string
_effortTitle
=
"Effort"
;
private
System
.
Windows
.
Threading
.
DispatcherTimer
_activityTimer
;
private
DateTime
_activityStartedUtc
;
private
DateTime
_lastVerbChangeUtc
;
private
int
_spinnerFrame
;
private
int
_verbIndex
;
/// <summary>Fixed caption, or empty while the rotating verbs are in charge.</summary>
private
string
_activityLabel
=
string
.
Empty
;
private
string
_activityDetail
=
string
.
Empty
;
public
ChatTranscriptView
(
)
{
InitializeComponent
(
)
;
Messages
=
new
ObservableCollection
<
ChatMessageViewModel
>
(
)
;
Messages
.
CollectionChanged
+=
OnMessagesChanged
;
MessagesList
.
ItemsSource
=
Messages
;
Loaded
+=
delegate
{
ApplyLinkAccentForTheme
(
)
;
}
;
Unloaded
+=
delegate
{
VSColorTheme
.
ThemeChanged
-=
OnVSColorThemeChanged
;
}
;
VSColorTheme
.
ThemeChanged
+=
OnVSColorThemeChanged
;
}
/// <summary>
/// The link/quote-border accent (<c>ChatLinkAccentBrush</c>) is a fixed blue rather than a VS
/// theme brush — no VS key reads well as link text in both themes — so it needs its own
/// light/dark swap instead of the automatic one <c>DynamicResource</c> gives real theme keys.
/// Written into <see cref="FrameworkElement.Resources"/> so every <see cref="MarkdownBlock"/>
/// row picks it up live via its style's <c>DynamicResource</c> setter, current rows and future
/// ones alike.
/// </summary>
private
void
ApplyLinkAccentForTheme
(
)
{
bool
isDarkTheme
=
true
;
try
{
var
windowBrush
=
(
SolidColorBrush
)
FindResource
(
VsBrushes
.
WindowKey
)
;
Color
color
=
windowBrush
.
Color
;
double
luminance
=
(
0.299
*
color
.
R
+
0.587
*
color
.
G
+
0.114
*
color
.
B
)
/
255
;
isDarkTheme
=
luminance
<
0.5
;
}
catch
(
Exception
ex
)
{
System
.
Diagnostics
.
Debug
.
WriteLine
(
$
"Chat link accent theme detection failed:
{
ex
.
Message
}
"
)
;
}
Resources
[
"ChatLinkAccentBrush"
]
=
new
SolidColorBrush
(
isDarkTheme
?
Color
.
FromRgb
(
0x5B
,
0xB4
,
0xFF
)
:
Color
.
FromRgb
(
0x1C
,
0x8A
,
0xE0
)
)
;
}
private
void
OnVSColorThemeChanged
(
ThemeChangedEventArgs
e
)
{
ApplyLinkAccentForTheme
(
)
;
}
/// <summary>Transcript rows, oldest first.</summary>
public
ObservableCollection
<
ChatMessageViewModel
>
Messages
{
get
;
}
/// <summary>Raised when the user clicks Stop. The parent control aborts the turn.</summary>
public
event
EventHandler
StopRequested
;
/// <summary>Raised when the composer's text should be sent as a prompt.</summary>
public
event
EventHandler
SendRequested
;
/// <summary>Files dropped onto the composer, so drag-and-drop attaches like the prompt box does.</summary>
public
event
EventHandler
<
string
[
]
>
FilesDropped
;
/// <summary>
/// Raised by one of the four selector buttons. The sender is the button, so the parent can
/// anchor its context menu to it; the argument says which selector was clicked.
/// <para>
/// Effort is the exception: it opens the built-in pill slider popup and reports through
/// <see cref="EffortChanged"/> instead, so it never reaches this event.
/// </para>
/// </summary>
public
event
EventHandler
<
ChatSelector
>
SelectorClicked
;
/// <summary>
/// Raised once the user is done with the effort slider — when the popup closes — carrying the
/// stop it was left on. Deliberately not raised per stop: applying a level restarts the agent,
/// so reporting each stop the user passes through would restart it once per stop and lock the
/// chat up while they are still choosing.
/// </summary>
public
event
EventHandler
<
int
>
EffortChanged
;
/// <summary>Raised by the ↻ button: clear and restart the current session.</summary>
public
event
EventHandler
ClearChatRequested
;
/// <summary>Raised by the ✚ button: open a new parallel chat session.</summary>
public
event
EventHandler
NewChatRequested
;
/// <summary>Raised by the ✎ button: rename the session currently in the tab.</summary>
public
event
EventHandler
RenameSessionRequested
;
/// <summary>
/// Raised by the 📎 button. The parent owns the file dialog and the attachment list, matching
/// the panel's own AttachDropdownButton — drag-and-drop and Ctrl+V paste already attach files
/// and images, but a picker is still the only way to browse to a file outside the editor.
/// </summary>
public
event
EventHandler
AttachRequested
;
/// <summary>
/// Raised by the ⚙/☰/⚡ mirror buttons. The sender is this view, so the parent can anchor the
/// panel's actual context menu (there is no separate copy of its contents) to whichever button
/// was clicked via <see cref="GetConfigMenuAnchor"/>.
/// </summary>
public
event
EventHandler
<
ChatConfigMenu
>
ConfigMenuClicked
;
/// <summary>
/// Raised by one of the mirrored promoted-toolbar buttons (<see cref="SetPromotedButtons"/>).
/// The argument is the same id passed into that call, so the parent can map it back to the
/// toolbar feature and invoke the same click handler the panel button does.
/// </summary>
public
event
EventHandler
<
string
>
PromotedButtonClicked
;
/// <summary>Raised when Ctrl+Scroll changes the zoom, so the parent can persist it.</summary>
public
event
EventHandler
<
double
>
ZoomChanged
;
/// <summary>
/// Raised when a link rendered inside the transcript's markdown is clicked. The parent decides
/// what the URL means — a web address opens in the browser, a bare path opens in the editor.
/// </summary>
public
event
EventHandler
<
string
>
LinkClicked
;
private
void
MarkdownBlock_LinkClicked
(
object
sender
,
string
url
)
{
LinkClicked
?
.
Invoke
(
this
,
url
)
;
}
/// <summary>
/// Raised by a tool row's ↗ icon with the file it acted on. A dedicated event rather than
/// reusing <see cref="LinkClicked"/>: the path here is never a guess (it came straight off the
/// tool call), so a miss is worth telling the user about, unlike a markdown auto-link that is
/// often just prose that happened to look like a path.
/// </summary>
public
event
EventHandler
<
string
>
ToolFileOpenRequested
;
private
void
OpenToolFile_Click
(
object
sender
,
RoutedEventArgs
e
)
{
var
message
=
(
sender
as
FrameworkElement
)
?
.
DataContext
as
ChatMessageViewModel
;
if
(
message
==
null
||
string
.
IsNullOrWhiteSpace
(
message
.
ToolFilePath
)
)
{
return
;
}
// The button already stops this click from also toggling the row's Expander (its own
// Click handling marks the routed mouse-up event handled before it bubbles that far), but
// an explicit mark here costs nothing and removes any doubt if that stops being true.
e
.
Handled
=
true
;
ToolFileOpenRequested
?
.
Invoke
(
this
,
message
.
ToolFilePath
)
;
}
/// <summary>
/// Raised on Ctrl+V in the composer. The parent owns the clipboard image pipeline (it also owns
/// the attachment list), and sets <see cref="ChatPasteEventArgs.Handled"/> when it consumed the
/// clipboard as an image — otherwise the text box performs its normal text paste.
/// </summary>
public
event
EventHandler
<
ChatPasteEventArgs
>
PasteRequested
;
/// <summary>Raised by ↑ on the first line of the composer: show the previous prompt.</summary>
public
event
EventHandler
HistoryPreviousRequested
;
/// <summary>Raised by ↓ on the last line of the composer: back towards the newest prompt.</summary>
public
event
EventHandler
HistoryNextRequested
;
/// <summary>Raised when the composer is resized by dragging, so the parent can persist the height.</summary>
public
event
EventHandler
<
double
>
ComposerHeightChanged
;
/// <summary>Text currently in the composer.</summary>
public
string
ComposerText
{
get
{
return
ComposerInput
.
Text
;
}
set
{
ComposerInput
.
Text
=
value
??
string
.
Empty
;
}
}
/// <summary>Chips host for the parent's attachment list. Presentation only — the parent fills it.</summary>
public
System
.
Windows
.
Controls
.
Panel
ComposerAttachmentsPanel
{
get
{
return
ComposerAttachments
;
}
}
/// <summary>
/// The composer's raw text box. Exposed (like <see cref="ComposerAttachmentsPanel"/>) so the
/// parent's "@" file/folder mention popup can drive it directly (caret index, selection,
/// character-rect positioning) the same way it drives the panel's own prompt box.
/// </summary>
public
TextBox
ComposerInputBox
{
get
{
return
ComposerInput
;
}
}
/// <summary>
/// Raised at the very top of the composer's PreviewKeyDown, before any of this view's own key
/// handling (Enter-to-send, history navigation, Escape). Lets the parent's "@" mention popup
/// claim Up/Down/Enter/Tab/Escape while it is open; setting <c>e.Handled</c> stops this view's
/// own handling too.
/// </summary>
public
event
EventHandler
<
System
.
Windows
.
Input
.
KeyEventArgs
>
ComposerPreviewKeyDown
;
/// <summary>
/// True while the user is typing in the composer. Prompt history writes to whichever prompt box
/// has the keyboard, so the panel and the chat tab never overwrite each other's text.
/// </summary>
public
bool
ComposerHasFocus
{
get
{
return
ComposerBar
.
Visibility
==
Visibility
.
Visible
&&
ComposerInput
.
IsKeyboardFocusWithin
;
}
}
/// <summary>Mirrors the panel's send-key preference so Enter behaves the same in both places.</summary>
public
bool
SendWithEnter
{
get
;
set
;
}
=
true
;
/// <summary>Mirrors the panel's Ctrl+Enter preference. Ignored while <see cref="SendWithEnter"/> is on.</summary>
public
bool
SendWithCtrlEnter
{
get
;
set
;
}
/// <summary>
/// How much of the composer is showing. <see cref="Hidden"/> outside native mode;
/// <see cref="ActionsOnly"/> while the chat is docked in the panel, where the panel's own
/// prompt box sits directly above the transcript and a second input box would just be
/// confusing — but the agent/model/effort/permission selectors and the mirrored config
/// buttons still need somewhere to live, since the panel hides its own equivalents in native
/// mode; <see cref="Full"/> once the chat has its own tab.
/// </summary>
public
enum
ComposerMode
{
Hidden
,
ActionsOnly
,
Full
}
private
ComposerMode
_composerMode
=
ComposerMode
.
Hidden
;
private
bool
_toolsMenuHasItems
=
true
;
private
bool
_customCommandsHasItems
=
true
;
/// <summary>
/// Shows or hides the composer, and — in <see cref="ComposerMode.ActionsOnly"/> — trims it
/// down to just the selectors and ↻/✚/✎/🎨, collapsing the resize grip, attachment chips,
/// text input and the mirrored config buttons (⚙/☰/⚡ and the promoted-toolbar mirrors). Those
/// mirrors only earn their keep once the chat has left for its own tab (<see cref="Full"/>) —
/// while docked in the panel (<see cref="ActionsOnly"/>) the panel's own copies are right
/// there in the same window, so showing both would just be clutter.
/// </summary>
public
void
SetComposerMode
(
ComposerMode
mode
)
{
_composerMode
=
mode
;
ComposerBar
.
Visibility
=
mode
==
ComposerMode
.
Hidden
?
Visibility
.
Collapsed
:
Visibility
.
Visible
;
Visibility
fullOnly
=
mode
==
ComposerMode
.
Full
?
Visibility
.
Visible
:
Visibility
.
Collapsed
;
ComposerResizeGrip
.
Visibility
=
fullOnly
;
ComposerAttachments
.
Visibility
=
fullOnly
;
ComposerInputBorder
.
Visibility
=
fullOnly
;
ComposerSettingsButton
.
Visibility
=
fullOnly
;
ComposerPromotedButtons
.
Visibility
=
fullOnly
;
UpdateToolsButtonVisibility
(
)
;
UpdateCustomCommandsButtonVisibility
(
)
;
// Only reachable while native mode is on (Hidden is the only mode outside it), so this is
// also the one place that needs to know: "Clear this conversation" doubles as "restart the
// agent" here, and the default tooltip doesn't say that.
ComposerClearButton
.
ToolTip
=
mode
==
ComposerMode
.
Hidden
?
"Clear this conversation and start fresh"
:
"Restart the agent and start a fresh conversation"
;
// ComposerBar's own Padding reserves room for the resize grip above and the input box
// below the action row — but in ActionsOnly those rows are collapsed (only the action
// row shows, docked in the panel), so the full bottom padding was rendering as dead
// space under the toolbar with nothing left to justify it.
ComposerBar
.
Padding
=
mode
==
ComposerMode
.
Full
?
new
Thickness
(
8
,
6
,
8
,
8
)
:
new
Thickness
(
8
,
6
,
8
,
2
)
;
// Docked in the panel the row has far less width than it does in a tab, and the switch
// between the two does not always change the row's own width — so the tier is re-picked
// here rather than waiting for a resize that may never come.
QueueComposerDensityRefresh
(
)
;
}
/// <summary>Combines the Full-only gate above with the "menu would be empty" gate below.</summary>
private
void
UpdateToolsButtonVisibility
(
)
{
ComposerToolsButton
.
Visibility
=
_composerMode
==
ComposerMode
.
Full
&&
_toolsMenuHasItems
?
Visibility
.
Visible
:
Visibility
.
Collapsed
;
}
/// <summary>Combines the Full-only gate above with the "no commands configured" gate below.</summary>
private
void
UpdateCustomCommandsButtonVisibility
(
)
{
ComposerCustomCommandsButton
.
Visibility
=
_composerMode
==
ComposerMode
.
Full
&&
_customCommandsHasItems
?
Visibility
.
Visible
:
Visibility
.
Collapsed
;
}
/// <summary>Captions of the agent / model / effort / permission selectors.</summary>
public
void
SetSelectorLabels
(
string
provider
,
string
model
,
string
effort
,
string
permission
)
{
_providerLabel
=
provider
??
"Agent"
;
_modelLabel
=
model
??
"Model"
;
_effortLabel
=
effort
??
"Effort"
;
_permissionLabel
=
permission
??
"Permissions"
;
// A longer caption can be what pushes the row past the edge, so the tier is re-picked here
// rather than only on resize — switching from "Ask" to "Skip permissions" is a resize as far
// as this row is concerned.
ApplyComposerDensity
(
_composerDensity
)
;
QueueComposerDensityRefresh
(
)
;
}
/// <summary>
/// The composer button a selector's menu should hang off, or null when there is nothing to hang
/// it on — native mode is off (<see cref="ComposerMode.Hidden"/>) or that particular selector
/// does not apply to the running agent. Resolves in <see cref="ComposerMode.ActionsOnly"/> too,
/// since the action row stays visible there. Used by the typed slash commands, which open the
/// same menus the buttons do; the caller falls back to its own anchor on null.
/// </summary>
public
UIElement
GetSelectorAnchor
(
ChatSelector
selector
)
{
if
(
ComposerBar
.
Visibility
!=
Visibility
.
Visible
)
{
return
null
;
}
Button
button
;
switch
(
selector
)
{
case
ChatSelector
.
Provider
:
button
=
ComposerProviderButton
;
break
;
case
ChatSelector
.
Model
:
button
=
ComposerModelButton
;
break
;
case
ChatSelector
.
Effort
:
button
=
ComposerEffortButton
;
break
;
default
:
button
=
ComposerPermissionButton
;
break
;
}
return
button
!=
null
&&
button
.
Visibility
==
Visibility
.
Visible
?
button
:
null
;
}
/// <summary>Enables or hides the selectors that do not apply to the running agent.</summary>
public
void
SetSelectorAvailability
(
bool
model
,
bool
effort
,
bool
permission
)
{
ComposerModelButton
.
Visibility
=
model
?
Visibility
.
Visible
:
Visibility
.
Collapsed
;
ComposerEffortButton
.
Visibility
=
effort
?
Visibility
.
Visible
:
Visibility
.
Collapsed
;
ComposerPermissionButton
.
Visibility
=
permission
?
Visibility
.
Visible
:
Visibility
.
Collapsed
;
// Fewer selectors may mean the full captions fit again, so this can widen the row as well
// as narrow it — the refresh always starts from the roomiest tier.
QueueComposerDensityRefresh
(
)
;
}
#region Composer density
/// <summary>
/// How much room the composer's action row is willing to spend on itself. Ordered widest
/// first: <see cref="RefreshComposerDensity"/> walks down the list and stops at the first tier
/// that fits, so a tab that is later widened climbs straight back to <see cref="Full"/>.
/// </summary>
private
enum
ComposerDensity
{
/// <summary>Full captions, roomy padding — what a docked-wide or floating tab shows.</summary>
Full
,
/// <summary>Shortened captions ("Skip" for "Skip permissions") and tighter button padding.</summary>
Compact
,
/// <summary>Compact, plus ↻/✚/✎/🎨 folded into the single "⋯" menu.</summary>
Tight
}
private
static
readonly
ComposerDensity
[
]
_composerDensityOrder
=
{
ComposerDensity
.
Full
,
ComposerDensity
.
Compact
,
ComposerDensity
.
Tight
}
;
private
ComposerDensity
_composerDensity
=
ComposerDensity
.
Full
;
private
bool
_composerDensityRefreshQueued
;
private
string
_providerLabel
=
"Agent"
;
private
string
_modelLabel
=
"Model"
;
private
string
_effortLabel
=
"Effort"
;
private
string
_permissionLabel
=
"Permissions"
;
// No vertical padding, matching ComposerSelectorStyle: the buttons are a fixed 22 tall, and
// padding there only shrinks the content slot below the glyph's own line height — which WPF
// answers by top-aligning the content and clipping what hangs out the bottom.
private
static
readonly
Thickness
_composerFullPadding
=
new
Thickness
(
7
,
0
,
7
,
0
)
;
private
static
readonly
Thickness
_composerTightPadding
=
new
Thickness
(
4
,
0
,
4
,
0
)
;
private
void
ComposerActionRow_SizeChanged
(
object
sender
,
SizeChangedEventArgs
e
)
{
if
(
!
e
.
WidthChanged
)
{
return
;
}
QueueComposerDensityRefresh
(
)
;
}
/// <summary>
/// Defers the tier decision to the end of the current layout pass. The refresh measures the
/// button row itself, which cannot be done from inside the arrange it would be reacting to, and
/// several of its triggers (labels, availability, mode) fire in a burst when the agent changes
/// — the queue collapses those into one pass.
/// </summary>
private
void
QueueComposerDensityRefresh
(
)
{
if
(
_composerDensityRefreshQueued
)
{
return
;
}
_composerDensityRefreshQueued
=
true
;
// Loaded priority so the row has been arranged (and therefore has an ActualWidth to
// measure against) before the tier is picked. The VS threading helpers cannot express a
// dispatcher priority, so the WPF dispatcher is the right tool here — every caller is
// already on the UI thread.
#pragma warning disable
VSTHRD001
,
VSTHRD110
Dispatcher
.
BeginInvoke
(
new
Action
(
(
)
=>
{
_composerDensityRefreshQueued
=
false
;
RefreshComposerDensity
(
)
;
}
)
,
System
.
Windows
.
Threading
.
DispatcherPriority
.
Loaded
)
;
#pragma warning restore
VSTHRD001
,
VSTHRD110
}
/// <summary>
/// Picks the widest density tier whose essential group (session actions + selectors) still fits
/// the row, so the controls that drive the agent stay readable in a narrow tab instead of being
/// clipped off the edge. The mirrored config buttons are deliberately not measured: they sit
/// after the essentials in the same scroller and are meant to be what scrolls out of view, so
/// promoting a row of toolbar buttons must not cost the selectors their captions.
/// </summary>
private
void
RefreshComposerDensity
(
)
{
if
(
ComposerBar
.
Visibility
!=
Visibility
.
Visible
)
{
return
;
}
double
available
=
ComposerActionRow
.
ActualWidth
-
2
;
if
(
available
<=
0
)
{
return
;
}
foreach
(
ComposerDensity
density
in
_composerDensityOrder
)
{
ApplyComposerDensity
(
density
)
;
// Unconstrained width on purpose: the group's own natural width is what has to be
// compared against the row, and the ScrollViewer above it measures with infinity too,
// so this asks exactly the question the scroller will answer.
ComposerEssentialGroup
.
Measure
(
new
Size
(
double
.
PositiveInfinity
,
double
.
PositiveInfinity
)
)
;
if
(
ComposerEssentialGroup
.
DesiredSize
.
Width
<=
available
)
{
break
;
}
}
// The measurements above ran against a constraint the layout system did not ask for; drop
// them so the next real pass measures the group the way its parent wants it.
ComposerEssentialGroup
.
InvalidateMeasure
(
)
;
}
private
void
ApplyComposerDensity
(
ComposerDensity
density
)
{
_composerDensity
=
density
;
bool
full
=
density
==
ComposerDensity
.
Full
;
int
maxChars
=
density
==
ComposerDensity
.
Compact
?
ComposerLabels
.
CompactMaxChars
:
ComposerLabels
.
TightMaxChars
;
ApplySelectorCaption
(
ComposerProviderButton
,
_providerLabel
,
"Change the AI agent"
,
full
,
maxChars
)
;
ApplySelectorCaption
(
ComposerModelButton
,
_modelLabel
,
"Change the model"
,
full
,
maxChars
)
;
ApplySelectorCaption
(
ComposerEffortButton
,
_effortLabel
,
"Change the effort level"
,
full
,
maxChars
)
;
ApplySelectorCaption
(
ComposerPermissionButton
,
_permissionLabel
,
"Change how tool permissions are handled"
,
full
,
maxChars
)
;
Thickness
padding
=
full
?
_composerFullPadding
:
_composerTightPadding
;
ComposerOverflowButton
.
Padding
=
padding
;
ComposerClearButton
.
Padding
=
padding
;
ComposerNewChatButton
.
Padding
=
padding
;
ComposerRenameSessionButton
.
Padding
=
padding
;
ComposerColorButton
.
Padding
=
padding
;
ComposerAttachButton
.
Padding
=
padding
;
ComposerProviderButton
.
Padding
=
padding
;
ComposerModelButton
.
Padding
=
padding
;
ComposerEffortButton
.
Padding
=
padding
;
ComposerPermissionButton
.
Padding
=
padding
;
// The five session actions are the only controls here with an equivalent elsewhere in the
// UI (the ⋯ menu, the tab's own context menu), which is why they are what gives way before
// the selectors do.
bool
folded
=
density
==
ComposerDensity
.
Tight
;
Visibility
unfolded
=
folded
?
Visibility
.
Collapsed
:
Visibility
.
Visible
;
ComposerOverflowButton
.
Visibility
=
folded
?
Visibility
.
Visible
:
Visibility
.
Collapsed
;
ComposerClearButton
.
Visibility
=
unfolded
;
ComposerNewChatButton
.
Visibility
=
unfolded
;
ComposerRenameSessionButton
.
Visibility
=
unfolded
;
ComposerColorButton
.
Visibility
=
unfolded
;
ComposerAttachButton
.
Visibility
=
unfolded
;
ComposerActionsSeparator
.
Visibility
=
unfolded
;
}
/// <summary>
/// Sets one selector's caption at the current tier, and — when the caption had to be shortened
/// — spells the full value out in the tooltip, so a truncated model id is never the only place
/// the selection is shown.
/// </summary>
private
static
void
ApplySelectorCaption
(
Button
button
,
string
label
,
string
tooltip
,
bool
full
,
int
maxChars
)
{
string
caption
=
full
?
label
:
ComposerLabels
.
Shorten
(
label
,
maxChars
)
;
button
.
Content
=
caption
+
" ▾"
;
button
.
ToolTip
=
string
.
Equals
(
caption
,
label
,
StringComparison
.
Ordinal
)
?
tooltip
:
tooltip
+
" ("
+
label
+
")"
;
}
#endregion
/// <summary>
/// Mirrors the panel's own "collapse ☰ when its dropdown would be empty" rule
/// (<c>RefreshToolbarLayout</c>'s <c>anyInDropdown</c>) onto the composer's ☰ mirror, so the
/// chat tab never offers a menu with nothing in it.
/// </summary>
public
void
SetToolsMenuHasItems
(
bool
hasItems
)
{
_toolsMenuHasItems
=
hasItems
;
UpdateToolsButtonVisibility
(
)
;
}
/// <summary>
/// Mirrors the panel's own "hide ⚡ when no custom commands are configured" rule
/// (<c>RefreshCustomCommandsButton</c>'s <c>hasAny</c>) onto the composer's ⚡ mirror, so the
/// chat tab never offers an empty custom-commands menu (issue #151 round 7).
/// </summary>
public
void
SetCustomCommandsMenuHasItems
(
bool
hasItems
)
{
_customCommandsHasItems
=
hasItems
;
UpdateCustomCommandsButtonVisibility
(
)
;
}
/// <summary>
/// The composer button a config menu should hang off, or null while the composer is
/// <see cref="ComposerMode.Hidden"/>. Same shape as <see cref="GetSelectorAnchor"/>.
/// </summary>
public
UIElement
GetConfigMenuAnchor
(
ChatConfigMenu
menu
)
{
if
(
ComposerBar
.
Visibility
!=
Visibility
.
Visible
)
{
return
null
;
}
Button
button
;
switch
(
menu
)
{
case
ChatConfigMenu
.
Tools
:
button
=
ComposerToolsButton
;
break
;
case
ChatConfigMenu
.
CustomCommands
:
button
=
ComposerCustomCommandsButton
;
break
;
default
:
button
=
ComposerSettingsButton
;
break
;
}
return
button
!=
null
&&
button
.
Visibility
==
Visibility
.
Visible
?
button
:
null
;
}
/// <summary>
/// The mirrored promoted-toolbar button with the given id, or null while the composer is
/// <see cref="ComposerMode.Hidden"/> or no button with that id was passed to
/// <see cref="SetPromotedButtons"/>.
/// </summary>
public
UIElement
GetPromotedButtonAnchor
(
string
id
)
{
if
(
ComposerBar
.
Visibility
!=
Visibility
.
Visible
||
ComposerPromotedButtons
.
Visibility
!=
Visibility
.
Visible
||
string
.
IsNullOrEmpty
(
id
)
)
{
return
null
;
}
foreach
(
Button
button
in
ComposerPromotedButtons
.
Children
)
{
if
(
id
.
Equals
(
button
.
Tag
as
string
,
StringComparison
.
Ordinal
)
)
{
return
button
;
}
}
return
null
;
}
/// <summary>
/// Rebuilds the mirrored promoted-toolbar buttons from the panel's own set. Each entry's
/// content and tooltip are copied from the panel button that owns it, so there is only one
/// place (<c>RefreshToolbarLayout</c>) that decides the glyph or wording — this never drifts
/// out of sync with what the panel itself shows.
/// </summary>
public
void
SetPromotedButtons
(
IEnumerable
<
(
string
Id
,
object
Content
,
object
ToolTip
)
>
buttons
)
{
ComposerPromotedButtons
.
Children
.
Clear
(
)
;
if
(
buttons
==
null
)
{
return
;
}
foreach
(
var
entry
in
buttons
)
{
var
button
=
new
Button
{
Style
=
(
Style
)
FindResource
(
"ComposerSelectorStyle"
)
,
Content
=
entry
.
Content
,
ToolTip
=
entry
.
ToolTip
,
FontSize
=
13
,
Tag
=
entry
.
Id
}
;
button
.
Click
+=
(
s
,
e
)
=>
PromotedButtonClicked
?
.
Invoke
(
this
,
(
(
Button
)
s
)
.
Tag
as
string
)
;
ComposerPromotedButtons
.
Children
.
Add
(
button
)
;
}
}
public
void
FocusComposer
(
)
{
// In ActionsOnly the text input is collapsed (the panel's own prompt box is still the one
// being typed into), so there is nothing here to focus.
if
(
_composerMode
!=
ComposerMode
.
Full
)
return
;
ComposerInput
.
Focus
(
)
;
ComposerInput
.
CaretIndex
=
ComposerInput
.
Text
.
Length
;
}
/// <summary>
/// Shows or hides the Stop button. Called when a turn starts and when it completes.
/// </summary>
public
void
SetBusy
(
bool
busy
)
{
StopButton
.
Visibility
=
busy
?
Visibility
.
Visible
:
Visibility
.
Collapsed
;
}
/// <summary>
/// Text on the footer line (token counts, cost, "working…"). Empty hides the bar.
/// <para>
/// Stops the live clock: this is the "here is a fixed sentence" entry point, and leaving the
/// timer running would overwrite that sentence a second later.
/// </para>
/// </summary>
public
void
SetStatus
(
string
text
)
{
StopActivityTimer
(
)
;
if
(
string
.
IsNullOrEmpty
(
text
)
)
{
StatusBar
.
Visibility
=
Visibility
.
Collapsed
;
StatusText
.
Text
=
string
.
Empty
;
return
;
}
StatusText
.
Text
=
text
;
StatusBar
.
Visibility
=
Visibility
.
Visible
;
}
#region Live turn clock
/// <summary>
/// Spinner frames. Geometric Shapes rather than the braille cells most CLI spinners use: braille
/// is absent from Segoe UI and only renders through a fallback font, which lands at a different
/// size and baseline than the text beside it.
/// </summary>
private
static
readonly
string
[
]
SpinnerFrames
=
{
"◐"
,
"◓"
,
"◑"
,
"◒"
}
;
/// <summary>
/// What the agent is said to be doing while it works. Rotating wording is the point: an
/// unchanging line reads as frozen, and the clock alone does not say the agent is still alive.
/// </summary>
private
static
readonly
string
[
]
WorkingVerbs
=
{
"Working"
,
"Thinking"
,
"Pondering"
,
"Perusing"
,
"Noodling"
,
"Percolating"
,
"Crunching"
,
"Mulling"
,
"Tinkering"
,
"Digging"
,
"Puzzling"
,
"Simmering"
,
"Cogitating"
,
"Whirring"
,
"Churning"
,
"Deliberating"
}
;
private
static
readonly
Random
VerbPicker
=
new
Random
(
)
;
/// <summary>Frame rate of the spinner. Fast enough to read as motion, cheap enough to ignore.</summary>
private
static
readonly
TimeSpan
SpinnerInterval
=
TimeSpan
.
FromMilliseconds
(
120
)
;
/// <summary>How long each verb stays up before the next one.</summary>
private
static
readonly
TimeSpan
VerbInterval
=
TimeSpan
.
FromSeconds
(
4
)
;
/// <summary>Prompts accepted while the current turn is running and waiting for their turn.</summary>
private
int
_queuedMessageCount
;
/// <summary>
/// Starts the spinner and the running clock on the status line. A long turn otherwise shows a
/// motionless "Working..." and gives no way to tell progress from a hang.
/// </summary>
public
void
BeginActivity
(
)
{
_activityLabel
=
string
.
Empty
;
_activityDetail
=
string
.
Empty
;
_activityStartedUtc
=
DateTime
.
UtcNow
;
_lastVerbChangeUtc
=
DateTime
.
UtcNow
;
_spinnerFrame
=
0
;
_verbIndex
=
VerbPicker
.
Next
(
WorkingVerbs
.
Length
)
;
if
(
_activityTimer
==
null
)
{
_activityTimer
=
new
System
.
Windows
.
Threading
.
DispatcherTimer
(
System
.
Windows
.
Threading
.
DispatcherPriority
.
Normal
)
{
Interval
=
SpinnerInterval
}
;
_activityTimer
.
Tick
+=
delegate
{
AdvanceActivity
(
)
;
}
;
}
_activityTimer
.
Start
(
)
;
StatusBar
.
Visibility
=
Visibility
.
Visible
;
StatusSpinner
.
Visibility
=
Visibility
.
Visible
;
RenderActivity
(
)
;
}
/// <summary>
/// Pins a fixed caption — "Waiting for your answer...", "Stopping..." — in place of the rotating
/// verbs. The spinner and the clock keep running: the turn is still open.
/// </summary>
public
void
SetActivityLabel
(
string
label
)
{
if
(
_activityTimer
==
null
||
!
_activityTimer
.
IsEnabled
)
{
return
;
}
_activityLabel
=
label
??
string
.
Empty
;
RenderActivity
(
)
;
}
/// <summary>Live token counts, appended after the elapsed time. Empty removes them.</summary>
public
void
SetActivityDetail
(
string
detail
)
{
if
(
_activityTimer
==
null
||
!
_activityTimer
.
IsEnabled
)
{
return
;
}
_activityDetail
=
detail
??
string
.
Empty
;
RenderActivity
(
)
;
}
/// <summary>
/// Shows how many follow-up messages are waiting without replacing the live token counts.
/// </summary>
public
void
SetQueuedMessageCount
(
int
count
)
{
_queuedMessageCount
=
Math
.
Max
(
0
,
count
)
;
if
(
_activityTimer
!=
null
&&
_activityTimer
.
IsEnabled
)
{
RenderActivity
(
)
;
}
}
/// <summary>Stops the clock and leaves the turn's final summary on the line.</summary>
public
void
EndActivity
(
string
summary
)
{
SetStatus
(
summary
)
;
}
/// <summary>How long the current turn has been running. Zero when none is.</summary>
public
TimeSpan
ActivityElapsed
{
get
{
return
_activityTimer
!=
null
&&
_activityTimer
.
IsEnabled
?
DateTime
.
UtcNow
-
_activityStartedUtc
:
TimeSpan
.
Zero
;
}
}
/// <summary>One animation frame: spin, and swap the verb when its turn is up.</summary>
private
void
AdvanceActivity
(
)
{
_spinnerFrame
=
(
_spinnerFrame
+
1
)
%
SpinnerFrames
.
Length
;
// Only while no fixed caption is pinned — "Waiting for your answer..." must not drift into
// "Percolating..." while the user reads the question it is asking about.
if
(
_activityLabel
.
Length
==
0
&&
DateTime
.
UtcNow
-
_lastVerbChangeUtc
>=
VerbInterval
)
{
_verbIndex
=
(
_verbIndex
+
1
)
%
WorkingVerbs
.
Length
;
_lastVerbChangeUtc
=
DateTime
.
UtcNow
;
}
RenderActivity
(
)
;
// Piggyback on the same tick to animate any still-running tool row (e.g. a subagent Task
// card, which can sit for minutes) instead of a static "still running" glyph. Every tool
// call happens within a BeginActivity/EndActivity window, so this timer is always live
// whenever a row can be running.
foreach
(
var
message
in
Messages
)
{
if
(
message
.
IsRunning
)
{
message
.
AdvanceSpinner
(
)
;
}
}
}
private
void
RenderActivity
(
)
{
StatusSpinner
.
Text
=
SpinnerFrames
[
_spinnerFrame
]
;
string
label
=
_activityLabel
.
Length
>
0
?
_activityLabel
:
WorkingVerbs
[
_verbIndex
]
+
"…"
;
string
text
=
label
+
" "
+
ChatFormatting
.
Duration
(
DateTime
.
UtcNow
-
_activityStartedUtc
)
;
if
(
!
string
.
IsNullOrEmpty
(
_activityDetail
)
)
{
text
+=
" · "
+
_activityDetail
;
}
if
(
_queuedMessageCount
>
0
)
{
text
+=
" · "
+
_queuedMessageCount
+
(
_queuedMessageCount
==
1
?
" message queued"
:
" messages queued"
)
;
}
StatusText
.
Text
=
text
;
}
private
void
StopActivityTimer
(
)
{
if
(
_activityTimer
!=
null
)
{
_activityTimer
.
Stop
(
)
;
}
StatusSpinner
.
Visibility
=
Visibility
.
Collapsed
;
}
#endregion
public
void
Clear
(
)
{
StopTracking
(
)
;
StopActivityTimer
(
)
;
Messages
.
Clear
(
)
;
_autoScroll
=
true
;
_queuedMessageCount
=
0
;
ScrollToEndButton
.
Visibility
=
Visibility
.
Collapsed
;
StopButton
.
Visibility
=
Visibility
.
Collapsed
;
// The card belongs to a conversation, not to the view: whoever clears the transcript is
// about to decide whether a new one is starting, and calls ShowWelcome if it is.
HideWelcome
(
)
;
}
#region Welcome card
/// <summary>
/// Fills and shows the card a fresh conversation opens on: what agent is running, against which
/// folder, and what the chat can do. Calling it again replaces the content, which is how the
/// caller fills in details that only arrive later (the CLI version, for one).
/// </summary>
/// <param name="title">Caption on the frame, e.g. <c>Claude Code v2.1.220</c>.</param>
/// <param name="facts">Lines under the mascot: model, effort, permissions, workspace.</param>
/// <param name="tips">Right-hand column, one line each.</param>
public
void
ShowWelcome
(
string
title
,
IEnumerable
<
string
>
facts
,
IEnumerable
<
string
>
tips
)
{
WelcomeTitle
.
Text
=
string
.
IsNullOrWhiteSpace
(
title
)
?
"Chat"
:
title
;
WelcomeFacts
.
ItemsSource
=
ToLines
(
facts
)
;
WelcomeTips
.
ItemsSource
=
ToLines
(
tips
)
;
WelcomeBanner
.
Visibility
=
Visibility
.
Visible
;
}
/// <summary>Drops the welcome card. Idempotent.</summary>
public
void
HideWelcome
(
)
{
WelcomeBanner
.
Visibility
=
Visibility
.
Collapsed
;
}
/// <summary>
/// True while the card is on screen. Callers that fill it in stages check this before redrawing,
/// so a late detail cannot bring back a card the first prompt already dismissed.
/// </summary>
public
bool
IsWelcomeVisible
{
get
{
return
WelcomeBanner
.
Visibility
==
Visibility
.
Visible
;
}
}
/// <summary>Copies the caller's sequence, skipping blanks, so the card never shows empty rows.</summary>
private
static
List
<
string
>
ToLines
(
IEnumerable
<
string
>
source
)
{
var
lines
=
new
List
<
string
>
(
)
;
if
(
source
!=
null
)
{
foreach
(
string
line
in
source
)
{
if
(
!
string
.
IsNullOrWhiteSpace
(
line
)
)
{
lines
.
Add
(
line
)
;
}
}
}
return
lines
;
}
#endregion
private
void
StopButton_Click
(
object
sender
,
RoutedEventArgs
e
)
{
StopRequested
?
.
Invoke
(
this
,
EventArgs
.
Empty
)
;
}
#region Composer
// The row scrolls horizontally but the mouse wheel is vertical by default, and there is no
// vertical content here for it to act on — without this, hovering the action row and scrolling
// does nothing instead of the horizontal scroll a user would expect.
private
void
ComposerActionsScroller_PreviewMouseWheel
(
object
sender
,
System
.
Windows
.
Input
.
MouseWheelEventArgs
e
)
{
if
(
ComposerActionsScroller
.
ScrollableWidth
<=
0
)
{
return
;
}
ComposerActionsScroller
.
ScrollToHorizontalOffset
(
ComposerActionsScroller
.
HorizontalOffset
-
e
.
Delta
)
;
e
.
Handled
=
true
;
}
private
const
double
ComposerActionsScrollStep
=
90
;
private
void
ComposerActionsScrollLeftButton_Click
(
object
sender
,
RoutedEventArgs
e
)
{
ComposerActionsScroller
.
ScrollToHorizontalOffset
(
ComposerActionsScroller
.
HorizontalOffset
-
ComposerActionsScrollStep
)
;
}
private
void
ComposerActionsScrollRightButton_Click
(
object
sender
,
RoutedEventArgs
e
)
{
ComposerActionsScroller
.
ScrollToHorizontalOffset
(
ComposerActionsScroller
.
HorizontalOffset
+
ComposerActionsScrollStep
)
;
}
// Small ◀/▶ buttons stand in for the scrollbar track (too heavy visually for a row this
// thin), so this is the only signal for whether either end still has something to reach —
// fires whenever the row's content width, viewport, or offset changes. They toggle
// Collapsed, not Hidden: both arrow columns are Grid "Auto" width, and the middle column
View remainder of file in raw view
Back
|
FazBrowse Home
|
New Git URL