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 ( ( ) => {
2427 setTheme ( "corbits-dark" ) ;
2528} ) ;
2629
2730/** Live heading colour in the transcript registry. */
28- function registryHeadingFg ( ) : RGBA | undefined {
29- return transcriptSyntaxStyle ( ) . getAllStyles ( ) . get ( "markup.heading.1" ) ?. fg ;
31+ function registryHeadingFg ( h : Harness ) : RGBA | undefined {
32+ return transcriptSyntaxStyle ( h . renderer )
33+ . getAllStyles ( )
34+ . get ( "markup.heading.1" ) ?. fg ;
3035}
3136
32- function painted ( spans : CapturedSpan [ ] , text : string , fg : RGBA ) : boolean {
33- return spans . some ( ( s ) => s . text . includes ( text ) && s . fg . equals ( fg ) ) ;
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 {
40+ try {
41+ style . getStyleCount ( ) ;
42+ return true ;
43+ } catch ( err ) {
44+ if ( err instanceof Error && err . message . includes ( "destroyed" ) ) return false ;
45+ throw err ;
46+ }
3447}
3548
36- async function settleSpans (
37- h : Harness ,
38- ready : ( spans : CapturedSpan [ ] ) => boolean ,
39- ) : Promise < CapturedSpan [ ] > {
40- let spans = h . captureSpans ( ) . lines . flatMap ( ( line ) => line . spans ) ;
41- for ( let i = 0 ; i < 400 && ! ready ( spans ) ; i ++ ) {
42- await h . renderOnce ( ) ;
43- await new Promise ( ( resolve ) => setTimeout ( resolve , 25 ) ) ;
44- spans = h . captureSpans ( ) . lines . flatMap ( ( line ) => line . spans ) ;
45- }
46- 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 ) ) ;
4751}
4852
4953describe ( "applyThemePinLive" , ( ) => {
@@ -64,7 +68,7 @@ describe("applyThemePinLive", () => {
6468 painted ( spans , "pin cycle" , darkHeading ) ,
6569 ) ;
6670 expect ( painted ( before , "pin cycle" , darkHeading ) ) . toBe ( true ) ;
67- expect ( registryHeadingFg ( ) ?. equals ( darkHeading ) ) . toBe ( true ) ;
71+ expect ( registryHeadingFg ( h ) ?. equals ( darkHeading ) ) . toBe ( true ) ;
6872
6973 // No cost movement anywhere in this cycle: the meter never moves,
7074 // so only an unconditional repaint repaints anything.
@@ -74,7 +78,7 @@ describe("applyThemePinLive", () => {
7478 expect ( applied ) . toBe ( "corbits-light" ) ;
7579 expect ( UI . name ) . toBe ( "corbits-light" ) ;
7680 expect ( UI . ground ) . not . toBe ( darkGround ) ;
77- expect ( registryHeadingFg ( ) ?. equals ( lightHeading ) ) . toBe ( true ) ;
81+ expect ( registryHeadingFg ( h ) ?. equals ( lightHeading ) ) . toBe ( true ) ;
7882
7983 const after = await settleSpans ( h , ( spans ) =>
8084 painted ( spans , "pin cycle" , lightHeading ) ,
@@ -106,14 +110,14 @@ describe("applyThemePinLive", () => {
106110 painted ( spans , "pin cycle" , lightHeading ) ,
107111 ) ;
108112 expect ( painted ( before , "pin cycle" , lightHeading ) ) . toBe ( true ) ;
109- expect ( registryHeadingFg ( ) ?. equals ( lightHeading ) ) . toBe ( true ) ;
113+ expect ( registryHeadingFg ( h ) ?. equals ( lightHeading ) ) . toBe ( true ) ;
110114
111115 const applied = applyThemePinLive ( "dark" , ( ) =>
112116 repaintShellForTheme ( shell ) ,
113117 ) ;
114118 expect ( applied ) . toBe ( "corbits-dark" ) ;
115119 expect ( UI . name ) . toBe ( "corbits-dark" ) ;
116- expect ( registryHeadingFg ( ) ?. equals ( darkHeading ) ) . toBe ( true ) ;
120+ expect ( registryHeadingFg ( h ) ?. equals ( darkHeading ) ) . toBe ( true ) ;
117121
118122 const after = await settleSpans ( h , ( spans ) =>
119123 painted ( spans , "pin cycle" , darkHeading ) ,
@@ -129,11 +133,118 @@ describe("applyThemePinLive", () => {
129133 ) ;
130134 } , 30000 ) ;
131135
132- test ( "auto re-resolves through the startup path and rebuilds the registry" , ( ) => {
136+ test ( "auto re-resolves through the startup path and rebuilds the registry" , async ( ) => {
133137 setTheme ( "corbits-dark" ) ;
134- const applied = applyThemePinLive ( "auto" , ( ) => undefined ) ;
135- expect ( [ "corbits-dark" , "corbits-light" ] ) . toContain ( applied ) ;
136- expect ( UI . name ) . toBe ( applied ) ;
137- expect ( registryHeadingFg ( ) ?. equals ( RGBA . fromHex ( UI . heading ) ) ) . toBe ( true ) ;
138+ await withTestRenderer ( async ( h ) => {
139+ const applied = applyThemePinLive ( "auto" , ( ) => undefined ) ;
140+ expect ( [ "corbits-dark" , "corbits-light" ] ) . toContain ( applied ) ;
141+ expect ( UI . name ) . toBe ( applied ) ;
142+ expect ( registryHeadingFg ( h ) ?. equals ( RGBA . fromHex ( UI . heading ) ) ) . toBe ( true ) ;
143+ } ) ;
144+ } ) ;
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+ ) ;
138249 } ) ;
139250} ) ;
0 commit comments