1111 */
1212
1313import { afterEach , describe , expect , test } from "bun:test" ;
14- import { RGBA , type CapturedSpan } from "@opentui/core" ;
14+ import { RGBA , SyntaxStyle , type CapturedSpan } from "@opentui/core" ;
1515
1616import { corbitsDark , corbitsLight , setTheme , UI } from "../theme.js" ;
1717import { transcriptSyntaxStyle } from "../stream.js" ;
1818import { withTestRenderer , type Harness } from "../harness.js" ;
19+ import { settleSpans } from "../test-helpers.js" ;
1920import { appendStreamRow } from "../shell/chrome.js" ;
2021import { createAppShell } from "../shell/index.js" ;
22+ import { setPromptRecognitionSource } from "../shell/internals.js" ;
23+ import { syncPromptHighlights } from "../shell/prompt.js" ;
2124import { applyThemePinLive , repaintShellForTheme } from "./settings.js" ;
2225
2326afterEach ( ( ) => {
@@ -31,34 +34,20 @@ function registryHeadingFg(h: Harness): RGBA | undefined {
3134 . get ( "markup.heading.1" ) ?. fg ;
3235}
3336
34- function painted ( spans : CapturedSpan [ ] , text : string , fg : RGBA ) : boolean {
35- return spans . some ( ( s ) => s . text . includes ( text ) && s . fg . equals ( fg ) ) ;
36- }
37-
38- /** 0.6.x leases a session buffer around each presented frame; a capture
39- * landing inside that window throws OutputBusy. The poll retries on the
40- * next round anyway, so a losing capture keeps polling instead of
41- * throwing — the ready predicate, never the trace, decides the outcome. */
42- function readSpans ( h : Harness ) : CapturedSpan [ ] {
37+ /** `destroy()` frees the native style and there is no finalizer; a later
38+ * read throws instead of returning a palette. */
39+ function syntaxStyleAlive ( style : SyntaxStyle ) : boolean {
4340 try {
44- return h . captureSpans ( ) . lines . flatMap ( ( line ) => line . spans ) ;
41+ style . getStyleCount ( ) ;
42+ return true ;
4543 } catch ( err ) {
46- if ( err instanceof Error && err . message . includes ( "OutputBusy " ) ) return [ ] ;
44+ if ( err instanceof Error && err . message . includes ( "destroyed " ) ) return false ;
4745 throw err ;
4846 }
4947}
5048
51- async function settleSpans (
52- h : Harness ,
53- ready : ( spans : CapturedSpan [ ] ) => boolean ,
54- ) : Promise < CapturedSpan [ ] > {
55- let spans = readSpans ( h ) ;
56- for ( let i = 0 ; i < 400 && ! ready ( spans ) ; i ++ ) {
57- await h . renderOnce ( ) ;
58- await new Promise ( ( resolve ) => setTimeout ( resolve , 25 ) ) ;
59- spans = readSpans ( h ) ;
60- }
61- return spans ;
49+ function painted ( spans : CapturedSpan [ ] , text : string , fg : RGBA ) : boolean {
50+ return spans . some ( ( s ) => s . text . includes ( text ) && s . fg . equals ( fg ) ) ;
6251}
6352
6453describe ( "applyThemePinLive" , ( ) => {
@@ -153,4 +142,109 @@ describe("applyThemePinLive", () => {
153142 expect ( registryHeadingFg ( h ) ?. equals ( RGBA . fromHex ( UI . heading ) ) ) . toBe ( true ) ;
154143 } ) ;
155144 } ) ;
145+
146+ test ( "a second pin destroys the transcript style the rows dropped" , async ( ) => {
147+ await withTestRenderer (
148+ async ( h ) => {
149+ setTheme ( "corbits-dark" ) ;
150+ const shell = createAppShell ( h . renderer , {
151+ terminal : { columns : 80 , rows : 40 } ,
152+ wireKeys : false ,
153+ } ) ;
154+ try {
155+ appendStreamRow ( shell , { role : "assistant" , text : "# pin cycle" } ) ;
156+ const first = transcriptSyntaxStyle ( h . renderer ) ;
157+ applyThemePinLive ( "light" , ( ) => repaintShellForTheme ( shell ) ) ;
158+ const second = transcriptSyntaxStyle ( h . renderer ) ;
159+ expect ( second ) . not . toBe ( first ) ;
160+ expect ( syntaxStyleAlive ( first ) ) . toBe ( false ) ;
161+ expect ( syntaxStyleAlive ( second ) ) . toBe ( true ) ;
162+
163+ applyThemePinLive ( "dark" , ( ) => repaintShellForTheme ( shell ) ) ;
164+ const third = transcriptSyntaxStyle ( h . renderer ) ;
165+ expect ( third ) . not . toBe ( second ) ;
166+ expect ( syntaxStyleAlive ( second ) ) . toBe ( false ) ;
167+ expect ( syntaxStyleAlive ( third ) ) . toBe ( true ) ;
168+ } finally {
169+ shell . dispose ( ) ;
170+ setTheme ( "corbits-dark" ) ;
171+ }
172+ } ,
173+ { width : 80 , height : 40 } ,
174+ ) ;
175+ } ) ;
176+
177+ test ( "refreshing the transcript cache leaves a style rows still paint" , async ( ) => {
178+ await withTestRenderer (
179+ async ( h ) => {
180+ setTheme ( "corbits-dark" ) ;
181+ const shell = createAppShell ( h . renderer , {
182+ terminal : { columns : 80 , rows : 40 } ,
183+ wireKeys : false ,
184+ } ) ;
185+ try {
186+ appendStreamRow ( shell , { role : "assistant" , text : "# pin cycle" } ) ;
187+ const painted = transcriptSyntaxStyle ( h . renderer ) ;
188+ setTheme ( "corbits-light" ) ;
189+ const rebuilt = transcriptSyntaxStyle ( h . renderer ) ;
190+ expect ( rebuilt ) . not . toBe ( painted ) ;
191+ expect ( syntaxStyleAlive ( painted ) ) . toBe ( true ) ;
192+ repaintShellForTheme ( shell ) ;
193+ expect ( syntaxStyleAlive ( painted ) ) . toBe ( false ) ;
194+ expect ( syntaxStyleAlive ( transcriptSyntaxStyle ( h . renderer ) ) ) . toBe (
195+ true ,
196+ ) ;
197+ } finally {
198+ shell . dispose ( ) ;
199+ setTheme ( "corbits-dark" ) ;
200+ }
201+ } ,
202+ { width : 80 , height : 40 } ,
203+ ) ;
204+ } ) ;
205+
206+ test ( "a pin destroys the prompt style only after the prompt drops it" , async ( ) => {
207+ await withTestRenderer (
208+ async ( h ) => {
209+ setTheme ( "corbits-dark" ) ;
210+ const shell = createAppShell ( h . renderer , {
211+ terminal : { columns : 80 , rows : 40 } ,
212+ wireKeys : false ,
213+ } ) ;
214+ setPromptRecognitionSource ( shell , ( ) => ( {
215+ commandNames : [ "implement" ] ,
216+ } ) ) ;
217+ const originalDestroy = SyntaxStyle . prototype . destroy ;
218+ let destroyedWhileHeld = false ;
219+ try {
220+ shell . prompt . value = "/implement" ;
221+ syncPromptHighlights ( shell ) ;
222+ const painted = shell . prompt . syntaxStyle ;
223+ expect ( painted ) . not . toBeNull ( ) ;
224+ if ( painted === null ) return ;
225+ SyntaxStyle . prototype . destroy = function destroy ( this : SyntaxStyle ) {
226+ if ( this === painted && shell . prompt . syntaxStyle === painted ) {
227+ destroyedWhileHeld = true ;
228+ }
229+ return originalDestroy . call ( this ) ;
230+ } ;
231+ applyThemePinLive ( "light" , ( ) => repaintShellForTheme ( shell ) ) ;
232+ expect ( shell . prompt . syntaxStyle ) . toBe ( painted ) ;
233+ expect ( syntaxStyleAlive ( painted ) ) . toBe ( true ) ;
234+ syncPromptHighlights ( shell ) ;
235+ expect ( destroyedWhileHeld ) . toBe ( false ) ;
236+ expect ( shell . prompt . syntaxStyle ) . not . toBe ( painted ) ;
237+ expect ( syntaxStyleAlive ( painted ) ) . toBe ( false ) ;
238+ const current = shell . prompt . syntaxStyle ;
239+ expect ( current ) . not . toBeNull ( ) ;
240+ if ( current !== null ) expect ( syntaxStyleAlive ( current ) ) . toBe ( true ) ;
241+ } finally {
242+ SyntaxStyle . prototype . destroy = originalDestroy ;
243+ shell . dispose ( ) ;
244+ setTheme ( "corbits-dark" ) ;
245+ }
246+ } ,
247+ { width : 80 , height : 40 } ,
248+ ) ;
249+ } ) ;
156250} ) ;
0 commit comments