diff --git a/src/patches/index.ts b/src/patches/index.ts index a1c69861..2e088007 100644 --- a/src/patches/index.ts +++ b/src/patches/index.ts @@ -73,6 +73,7 @@ import { writeWorktreeMode } from './worktreeMode'; import { writeAllowCustomAgentModels } from './allowCustomAgentModels'; import { writeVoiceMode } from './voiceMode'; import { writeChannelsMode } from './channelsMode'; +import { writeSessionColor } from './sessionColor'; import { restoreNativeBinaryFromBackup, restoreClijsFromBackup, @@ -174,6 +175,13 @@ const PATCH_DEFINITIONS = [ group: PatchGroup.ALWAYS_APPLIED, description: `Statusline updates will be properly throttled instead of queued (or debounced)`, }, + { + id: 'session-color', + name: 'Session color from env', + group: PatchGroup.ALWAYS_APPLIED, + description: + 'Set session prompt bar color via TWEAKCC_SESSION_COLOR env var', + }, // Misc Configurable { id: 'model-customizations', @@ -663,6 +671,9 @@ export const applyCustomization = async ( ), condition: config.settings.misc?.statuslineThrottleMs != null, }, + 'session-color': { + fn: c => writeSessionColor(c), + }, // Misc Configurable 'patches-applied-indication': { fn: c => diff --git a/src/patches/sessionColor.test.ts b/src/patches/sessionColor.test.ts new file mode 100644 index 00000000..41488a0e --- /dev/null +++ b/src/patches/sessionColor.test.ts @@ -0,0 +1,96 @@ +import { describe, it, expect } from 'vitest'; +import { writeSessionColor, patchSaveAgentColor } from './sessionColor'; + +const makeSaveAgentColor = () => + ';async function Mr$(H,$,q){let K=q??sT(H);' + + 'if(Hv(K,{type:"agent-color",agentColor:$,sessionId:H}),H===V$())' + + 'WA().currentSessionAgentColor=$;c("tengu_agent_color_set",{})}'; + +const makeCLIState = () => + 'effortValue:oR(w.effort),' + + 'activeOverlays:new Set,fastMode:cP8(N5),' + + '...(uF()&&d1&&{advisorModel:d1})'; + +const makeDefaultState = () => + 'effortValue:void 0,' + 'activeOverlays:new Set,fastMode:!1}'; + +const makeBoth = () => + 'first{' + makeDefaultState() + 'second{' + makeCLIState(); + +const makeFullFile = () => makeBoth() + makeSaveAgentColor(); + +describe('sessionColor', () => { + describe('writeSessionColor', () => { + it('should inject into CLI initialState and patch saveAgentColor', () => { + const result = writeSessionColor(makeFullFile()); + expect(result).not.toBeNull(); + expect(result).toContain('TWEAKCC_SESSION_COLOR'); + expect(result).toContain('{name:"",color:__c}'); + expect(result).toContain('__tweakccSaveAgentColor'); + }); + + it('should inject into default app state', () => { + const input = makeDefaultState() + makeSaveAgentColor(); + const result = writeSessionColor(input); + expect(result).not.toBeNull(); + expect(result).toContain('TWEAKCC_SESSION_COLOR'); + }); + + it('should patch both initialState locations', () => { + const result = writeSessionColor(makeFullFile())!; + expect(result).not.toBeNull(); + const count = (result.match(/TWEAKCC_SESSION_COLOR/g) || []).length; + expect(count).toBe(2); + }); + + it('should validate color against allowed list', () => { + const result = writeSessionColor(makeFullFile())!; + expect(result).toContain('.includes(__c)'); + expect(result).toContain('"green"'); + expect(result).toContain('"cyan"'); + }); + + it('should be idempotent', () => { + const first = writeSessionColor(makeFullFile())!; + const second = writeSessionColor(first)!; + expect(second).toBe(first); + }); + + it('should return null when no pattern found', () => { + const result = writeSessionColor('not a valid file'); + expect(result).toBeNull(); + }); + + it('should schedule color save via queueMicrotask', () => { + const result = writeSessionColor(makeFullFile())!; + expect(result).toContain('queueMicrotask'); + expect(result).toContain('__tweakccSaveAgentColor'); + }); + + it('should expose saveAgentColor on globalThis', () => { + const result = writeSessionColor(makeFullFile())!; + expect(result).toContain( + 'globalThis.__tweakccSaveAgentColor=(c)=>Mr$(V$(),c)' + ); + }); + }); + + describe('patchSaveAgentColor', () => { + it('should find and patch saveAgentColor', () => { + const result = patchSaveAgentColor(makeSaveAgentColor()); + expect(result).not.toBeNull(); + expect(result).toContain('globalThis.__tweakccSaveAgentColor'); + }); + + it('should return null when pattern not found', () => { + const result = patchSaveAgentColor('no match here'); + expect(result).toBeNull(); + }); + + it('should preserve original function', () => { + const result = patchSaveAgentColor(makeSaveAgentColor())!; + expect(result).toContain('async function Mr$'); + expect(result).toContain('type:"agent-color"'); + }); + }); +}); diff --git a/src/patches/sessionColor.ts b/src/patches/sessionColor.ts new file mode 100644 index 00000000..89c8e783 --- /dev/null +++ b/src/patches/sessionColor.ts @@ -0,0 +1,118 @@ +import { showDiff } from './index'; +import { debug } from '../utils'; + +const VALID_COLORS = [ + 'red', + 'blue', + 'green', + 'yellow', + 'purple', + 'orange', + 'pink', + 'cyan', +]; + +const INJECTION = + `,standaloneAgentContext:` + + `(()=>{` + + `let __c=process.env.TWEAKCC_SESSION_COLOR;` + + `if(!__c||!${JSON.stringify(VALID_COLORS)}.includes(__c))return void 0;` + + `queueMicrotask(()=>{` + + `if(globalThis.__tweakccSaveAgentColor)globalThis.__tweakccSaveAgentColor(__c)` + + `});` + + `return{name:"",color:__c}` + + `})()`; + +export const writeSessionColor = (oldFile: string): string | null => { + if ( + oldFile.includes( + 'standaloneAgentContext:(()=>{let __c=process.env.TWEAKCC_SESSION_COLOR;' + ) + ) { + return oldFile; + } + + const patterns = [ + /,activeOverlays:new Set,fastMode:[$\w]+\([$\w]+\)/, + /,activeOverlays:new Set,fastMode:!1\}/, + ]; + + let result = oldFile; + let patched = false; + + for (const pattern of patterns) { + const match = result.match(pattern); + if (!match || match.index === undefined) continue; + + const prePatch = result; + const replacement = INJECTION + match[0]; + result = + prePatch.slice(0, match.index) + + replacement + + prePatch.slice(match.index + match[0].length); + + showDiff( + prePatch, + result, + INJECTION, + match.index, + match.index + match[0].length + ); + patched = true; + } + + if (!patched) { + debug('patch: sessionColor: failed to find app state init patterns'); + return null; + } + + const saveColorResult = patchSaveAgentColor(result); + if (!saveColorResult) { + debug('patch: sessionColor: failed to patch saveAgentColor'); + return null; + } + + return saveColorResult; +}; + +export const patchSaveAgentColor = (oldFile: string): string | null => { + const pattern = new RegExp( + '([,;{}])' + + '(async function ([$\\w]+)' + + '\\(([$\\w]+),([$\\w]+),([$\\w]+)\\)' + + '\\{let [$\\w]+=\\6\\?\\?[$\\w]+\\(\\4\\);' + + 'if\\([$\\w]+\\([$\\w]+,' + + '\\{type:"agent-color",agentColor:\\5,sessionId:\\4\\}\\),' + + '\\4===([$\\w]+)\\(\\)\\))' + ); + const match = oldFile.match(pattern); + if (!match || match.index === undefined) { + return null; + } + + const delimiter = match[1]; + const funcBody = match[2]; + const funcName = match[3]; + const getSessionIdName = match[7]; + + const injection = + `globalThis.__tweakccSaveAgentColor=` + + `(c)=>${funcName}(${getSessionIdName}(),c);`; + + const replacement = `${delimiter}${injection}${funcBody}`; + + const result = + oldFile.slice(0, match.index) + + replacement + + oldFile.slice(match.index + match[0].length); + + showDiff( + oldFile, + result, + injection, + match.index, + match.index + match[0].length + ); + + return result; +};