2727import com .microsoft .copilot .eclipse .ui .utils .UiUtils ;
2828
2929/**
30- * Collapsible "Thinking" banner shown above an assistant turn while the model emits thinking deltas .
30+ * Collapsible "Thinking" banner shown above an assistant turn while the model emits thinking stream .
3131 *
3232 * <p>Pure view: callers drive the visual state via {@link #showCompleted(String)} and {@link #showCancelled()}.
3333 * The owning turn widget is responsible for cancellation events and title fetching.
@@ -48,6 +48,14 @@ class ThinkingBlock extends Composite {
4848 private final StringBuilder textBuffer = new StringBuilder ();
4949 private boolean expanded = true ;
5050
51+ /**
52+ * Lifecycle of the block. Transitions are always forward: STREAMING → (SEALED →)? → COMPLETED|CANCELLED.
53+ * SEALED means {@code sealThinking()} has fired and a title fetch is in flight; new thinking stream fragments must
54+ * start a new block.
55+ */
56+ private enum State { STREAMING , SEALED , COMPLETED , CANCELLED }
57+
58+ private State state = State .STREAMING ;
5159 private SpinnerAnimator spinner ;
5260 private Image cancelledIcon ;
5361 private Image downArrowImage ;
@@ -76,7 +84,7 @@ public ThinkingBlock(Composite parent, int style) {
7684 updateChevron ();
7785 }
7886
79- /** Append a thinking delta . Null/empty fragments are ignored. */
87+ /** Append a thinking stream fragment . Null/empty fragments are ignored. */
8088 public void appendText (String fragment ) {
8189 if (fragment == null || fragment .isEmpty ()) {
8290 return ;
@@ -100,6 +108,7 @@ public void showCompleted(String title) {
100108 }
101109 setTitleText (title );
102110 setExpanded (false );
111+ state = State .COMPLETED ;
103112 }
104113
105114 /** Swap to the cancel icon, set the cancelled title, and collapse. No-op if already finalized. */
@@ -117,11 +126,27 @@ public void showCancelled() {
117126 }
118127 setTitleText (Messages .thinking_cancelledTitle );
119128 setExpanded (false );
129+ state = State .CANCELLED ;
120130 }
121131
122- /** True once the spinner has stopped (block has been completed or cancelled). */
132+ /**
133+ * Mark the block as sealed: the owning widget has requested a title and any further thinking stream fragments must
134+ * land in a new block. No-op once the block has been finalized or already sealed.
135+ */
136+ public void markSealed () {
137+ if (state == State .STREAMING ) {
138+ state = State .SEALED ;
139+ }
140+ }
141+
142+ /** True only while new thinking stream fragments should still be appended to this block. */
143+ public boolean isAcceptingThinkStream () {
144+ return state == State .STREAMING ;
145+ }
146+
147+ /** True once the block has been completed or cancelled (spinner stopped, final title shown). */
123148 public boolean isFinalized () {
124- return spinner == null ;
149+ return state == State . COMPLETED || state == State . CANCELLED ;
125150 }
126151
127152 /** The full accumulated thinking text streamed so far. */
@@ -191,8 +216,13 @@ public void mouseUp(MouseEvent e) {
191216 toggleExpanded ();
192217 }
193218 };
219+ // Attach to every header child (and the header itself) so the entire area that shows the hand
220+ // cursor is actually clickable. iconLabel is intentionally excluded: it hosts the live spinner
221+ // animation (and the cancel icon afterwards), and a clickable spinner is an odd affordance.
222+ header .addMouseListener (toggleListener );
194223 titleText .addMouseListener (toggleListener );
195224 chevronLabel .addMouseListener (toggleListener );
225+ filler .addMouseListener (toggleListener );
196226 }
197227
198228 private void createBody () {
@@ -256,8 +286,10 @@ private static List<ParsedSection> parseSections(String raw) {
256286 String currentTitle = null ;
257287 int cursor = 0 ;
258288 while (matcher .find ()) {
259- String body = raw .substring (cursor , matcher .start ()).strip ();
260- if (currentTitle != null || !body .isEmpty ()) {
289+ // Preserve the body's original whitespace (e.g. leading indentation for code blocks); only strip the
290+ // trailing newline(s) that visually separate the body from the upcoming title delimiter.
291+ String body = stripTrailingNewlines (raw .substring (cursor , matcher .start ()));
292+ if (currentTitle != null || !body .isBlank ()) {
261293 result .add (new ParsedSection (currentTitle , body ));
262294 }
263295 currentTitle = matcher .group (1 ).trim ();
@@ -267,13 +299,22 @@ private static List<ParsedSection> parseSections(String raw) {
267299 cursor ++;
268300 }
269301 }
270- String tail = raw .substring (cursor ).strip ();
271- if (currentTitle != null || !tail .isEmpty ()) {
302+ // Tail has no following title delimiter; only trim trailing newlines so leading indentation survives.
303+ String tail = stripTrailingNewlines (raw .substring (cursor ));
304+ if (currentTitle != null || !tail .isBlank ()) {
272305 result .add (new ParsedSection (currentTitle , tail ));
273306 }
274307 return result ;
275308 }
276309
310+ private static String stripTrailingNewlines (String s ) {
311+ int end = s .length ();
312+ while (end > 0 && s .charAt (end - 1 ) == '\n' ) {
313+ end --;
314+ }
315+ return s .substring (0 , end );
316+ }
317+
277318 private void setTitleText (String text ) {
278319 if (titleViewer == null || titleViewer .getTextWidget ().isDisposed ()) {
279320 return ;
0 commit comments