See WIP.md for the maintenance guide. This file covers the planned twinBASIC IDE add-in that shows the documentation for the symbol under the cursor, and the harness that tests IDE add-ins by machine, which the add-in is developed against.
Status: Stages 1 and 2 are done, and Stage 3's index is built --- addin-test.bat
operates Samples 10 and 15 end to end and leaves the registry as it found it; all fourteen
of Stage 2's questions are answered, twelve of them by eight probe lanes that fail when a
later IDE build behaves differently; and every build publishes tB/symbols.json, 5,536
names at 4,086 URLs, under a drift guard that fails the build when one of its URLs goes.
Stage 3's embedded mode, a theme parameter the site would read, waits until the add-in
works (decided 2026-09-25). Stage 4, the add-in itself, is next. This file
replaces the June draft, add-in/PLAN.md
in commit d159acf8 ("Roughly plan the help add-in"). That commit is on no branch --- only
the detached HEAD of an old worktree keeps it --- so everything in it worth keeping is here,
corrected, and nothing depends on it surviving. What changed from the June
draft lists the corrections.
Facts about the IDE are from BETA 983. An offset like main.js@611152 is a byte offset
into the one-line ide/main.js; offsets move with every build, so in any other build search
for the quoted text instead. Each fact is one of three kinds:
- stated plainly: read in the IDE's code or registry;
- marked (reported): from a static reading or a harvested finding, not re-checked;
- marked with a probe number (P1 to P14): only running the IDE can settle it. Stage 2 lists them.
- Help for the symbol under the cursor. A key press opens the documentation page for the identifier at the cursor, or for the selection. F1, unless P1/P2 rule it out.
- A help pane. A dockable tool window with search and a page view, so that reading the documentation does not mean leaving the IDE.
- Theme-aware. It follows the IDE's light, dark and classic themes.
It also makes a documentation promise real. Permanent
Links says that "the IDE help system" relies on the
/tB/ URLs, but the IDE has no documentation help today: its scripts contain neither
docs.twinbasic.com nor /tB/ (reported). This add-in is the first real consumer of that
contract, and it is what WIP.Authoring.md
waits on before [Description] text and these pages can be connected.
WIP.tbIDE.md has the whole API. An add-in is a Standard DLL that exports
tbCreateCompilerAddin(Host) As AddIn. Through Host it gets toolbar buttons, tool windows
built through a DOM API, KeyboardShortcuts.Add, the active CodeEditor (text, selection,
cursor), the project's virtual file system, the theme, the DEBUG CONSOLE, notifications and
message boxes. It has no call to open a URL, none to run an IDE command, and none to ask
the compiler what a symbol is. Each of those gaps shapes a stage below.
- The compiler loads add-ins, not the page.
bin/twinBASIC_win64.dllbuilds the search path at run time from the pieces\,addins,\,win64,\,*.dll, so its root folders cannot be read off the binary. There are two. One is the install's: the add-in samples build into${IdePath}\addins\${Architecture}\, and the shipped install hasaddins\win32\andaddins\win64\besidebin\, each holdingtbGlobalSearchAddIn1.dll--- so every IDE thattbbuild,tbrunandexamples.batstart today loads the Global Search add-in. Measured through the compiler's own list (loadedAddinsintb-ide.mjs): the real install's IDE reportsGlobalSearchAddIn AddIn, and a copy of it with emptyaddinsfolders reports none. The other is the user's, in the next item. - The compiler also loads the add-ins in
%APPDATA%\twinBASIC\addins\<arch>(P6). The page makes that folder at startup. It gives the host'sCreateCommonFolders(main.js@961019) the text%APPDATA%\twinBASIC, which the host expands in the IDE's own environment, createspackages,themes,locale,addins\win32andaddins\win64in, and returns. The page keeps the path ascommonFolderRootPathand sends it withRequestStartCompilerandRequestLoadAddins(main.js@1047705and@1048667). Measured on BETA 983 by test/addin/appdata.test.mjs, with a probe add-in that prints the file it was loaded from: an IDE started withAPPDATAnaming a folder of the lane's own loaded the probe from<APPDATA>\twinBASIC\addins\win32, and not a second copy placed inaddinsitself. The compiler loads from the folder it is sent, not from its own%APPDATA%: withcommonFolderRootPathpointed at a second folder and the compiler restarted, it loaded the copy there, while the probe still read the first folder in its ownAPPDATA. So a DLL in the user's folder loads into every IDE the user starts, from any install, and into every IDEtbbuild,tbrunandexamples.batstart. An IDE started withAPPDATAnaming another folder loads none of the user's add-ins, which is how the add-in lanes keep them out (Stage 1, item 3). None of it needed a DLL in the user's own folder. The FAQ's answer to "Does twinBASIC support addins?" namedaddins\without thewin32andwin64folders under it. It names them now, and says that a DLL placed inaddins\itself is not loaded. - An add-in runs inside the compiler's process. Measured (P10): the process id an
add-in read with
GetCurrentProcessIdwas that oftwinBASIC_win32_noDEP.exe, whichtwinBASIC.exestarts as a direct child, beside the page servertwinBASIC_win32.exe --ide=<pid>. So an add-in sees the environment the IDE was started with. A compiler restart is a new process: the toolbar's restart button (#restartIcon, bound totbCompiler_Restart, which callsroot.forceTerminate()) ended the compiler, and the next one, with a new process id, loaded the add-in again and ran itsOnProjectLoadeda second time. The DEBUG CONSOLE was not cleared; the IDE addedrestarting from MEMORY [<project>]. - Load failures have their own messages in the compiler's strings:
Failed to load addin. LoadLibrary() failed.,Entry point not found. Addin may have been compiled for a newer version of the twinBASIC IDE.,Entry point 'tbCreateCompilerAddin' call failed.and...returned an object that does not implement interface IAddInV1.They go to the DEBUG CONSOLE, after the file's name in brackets --- measured with a 32-bit add-in inaddins\win64:[InFolder_win64.dll] Failed to load addin. LoadLibrary() failed.An add-in that failed to load is still in the compiler's list, asUnknown Addin, so a test looks for the name it expects rather than counting. - Holding Shift while a project opens skips the add-ins. The page sends
RequestLoadAddinsonly whenshiftKeyDownis false, and otherwise writes[IDE] SHIFT KEY DETECTED: DISABLED LOADING OF ADDINSto the DEBUG CONSOLE (main.js@1048443).shiftKeyDownfollows the keymap'stbMisc_ShiftKeyStateDownand...Upactions, so a test that presses Shift must not do it while a project is opening. - The linker exports
tbCreateCompilerAddinastbCreateCompilerAddin_v3, and the loader takes three names (P14). Read in the compiler's code and measured on BETA 983 by test/addin/entry.test.mjs. The loader --- in BETA 983 the code at0x1EAB6275intwinBASIC_win32.dll, and in another build the code that pushes the address of the stringtbCreateCompilerAddin_v2, found withdumpbin /disasm; the same intwinBASIC_win64.dll--- asksGetProcAddressfortbCreateCompilerAddin, thentbCreateCompilerAddin_v2, thentbCreateCompilerAddin_v3, and calls the first it finds the same way whichever it is: one argument, theHost,stdcallon win32. It asks what that returns forIAddInV1---{F1BAB9A7-09A3-436C-8B57-A57A76C5DF98}, the package's own --- and reads itsName. The linker, for each[DllExport]function, swaps the export name fortbCreateCompilerAddin_v3when the function's name istbCreateCompilerAddin, and exports nothing under the name written. So the three names are not three signatures: the suffix is a version stamp, and an IDE whose loader does not know the stamp refuses the DLL. Measured with the probe's one export patched in four copies: the plain name,_v2and_v3all loaded, and_v4did not, with[EntryV4.dll] Failed to load addin. Entry point not found. Addin may have been compiled for a newer version of the twinBASIC IDE.in the DEBUG CONSOLE and anUnknown Addinin the compiler's list. Both of P7's builds, win32 and win64, exported_v3alone. Every install on this machine, BETA 947 to 983, has the same three names in its loader, and each one's shipped Global Search add-in exports_v3alone, so both stamps are older than BETA 947. - Add-ins cannot be switched off. The Add-Ins menu lists the loaded add-ins with ticks,
and every item calls
notSupportedMenuOption()(reported). - A compiler restart loads every add-in again, from the file in its folder then (P9).
Measured on BETA 983 by test/addin/reload.test.mjs. A
restart is the toolbar's restart button, every switch of the build target (below), and the
IDE's own restart after a compiler crash. The page's
restartCompiler(main.js@153451) first takes away what every add-in added:removeAddinAlterationsremoves their toolbar buttons and shortcuts, and hides each tool window's body behind the text(currently unavailable), leaving the window where it was. It ends the old compiler withtaskkill /F(forceTerminate,main.js@1050553), so no add-in'sClass_Terminateruns: measured with the probe writing a line to a file from it, which it did for an object it dropped as it loaded and never for itself. The new compiler then loads the add-ins as it starts, and each runsOnProjectLoadedagain, in a new process, so an add-in keeps no state across a restart. A window it adds again under the same id is the same window, emptied and shown again (below, under Tool windows). So the rebuild loop works without ending the IDE: rename the loaded DLL aside, which P8 allows, put the new build in its place, and restart the compiler. Measured: with build A renamed aside, a restart loaded nothing, left no button or shortcut, and left both of A's windows showing the text; the renamed file could be deleted, since the compiler that held it had ended. With build B then copied in under A's name, the next restart loaded B, whose first line came 1.0 s after the click (4.4 s with another lane building at the same time); B had one button, one shortcut that fired B alone, and A's two windows, filled with B's content. A restart's compile settled after 6.6 s, against 7.6 s for a new IDE to open the project and settle its compile, so a harness saves little by the loop; a person keeps the IDE, its open files and its layout. - The build target picks the compiler, and the compiler picks the folder (P7). The IDE
remembers the target of each project in the shared registry, as one JSON object in
IDESettings\targetArchitectureMemorykeyed by project path, and opens a project in the target remembered for it --- or, with none, in the first on its list, win32. Measured with a differently named DLL in each folder: a project with no memory gottwinBASIC_win32_noDEP.exe, which loadedaddins\win32alone; a project remembered as win64 gottwinBASIC_win64_noDEP.exewithtwinBASIC_nativedbg_win64.exe, which triedaddins\win64alone. Switching the target of an open project (Ctrl+F1 / Ctrl+F2) restarts the compiler in the other bitness, and the new one loads the other folders.changedActiveBuildConfiginide/main2.jsrecords the new target and kills the compiler. Measured on BETA 983 by test/addin/arch.test.mjs, with a 32-bit and a 64-bit build of a probe in both folders of their bitness, the install's and%APPDATA%'s: the project opened in win32, whose compiler loaded the two 32-bit copies alone; a switch to win64 startedtwinBASIC_win64_noDEP.exe, which loaded the two 64-bit copies alone, each reporting that it ran 64-bit; and a switch back loaded the 32-bit ones again. A switch is a restart, with all that the item above says of one. A shipped add-in needs both builds, and one that should keep working across a switch needs both installed.
Read at main.js@608242, @610953 and @611152, and measured on BETA 983 by P1 and P2,
whose lane is test/addin/keys.test.mjs.
KeyboardShortcuts.Addlowercases the key string, deletes its whitespace and stores it as it is:{CTRL}{SHIFT}d,{SHIFT}DandF1were stored as{ctrl}{shift}d,{shift}dandf1. Matching is a plain string comparison against{ctrl}+{shift}+{alt}+ the key, built in that order --- so{SHIFT}{CTRL}dcould never match, whatever else is true.- Add-in shortcuts are matched on key-up, in
document.onkeyup, after the IDE's own handling of that key-up. The built-in bindings run on key-down, in a capture-phase listener. The key-up only dispatches if the same key's key-down was recorded less than 500 ms earlier. - The key-down is recorded only when Ctrl and Alt are not held:
if((!e.ctrlKey||e.key==="Control")&&(!e.altKey||e.key==="Alt")){realKeyPresses[o]=performance.now()}. So a shortcut containing{ctrl}or{alt}does not fire (P1). Pressed with nothing focused,{ctrl}{shift}d,{ctrl}dand{alt}ffired nothing, whiled,{shift}d,f1and{shift}f1all fired. A record is never cleared, so such a shortcut does fire when the same key was pressed on its own less than 500 ms before: D, then Ctrl+D and Ctrl+Shift+D, and F, then Alt+F, fired all three. So the SDK's own example,{CTRL}{SHIFT}d, does not work. The bug is in BUGS-TO-REPORT.md, and the published KeyboardShortcuts page has a NOTE, the prefix-order rule, which keys fire, and an example on Shift+F12. - F1 fires, and is shared with the IDE (P2). The default keymap binds it to
tbHelp_ToggleExpandSignatureHelpon key-down (Window.md lists the keymap), a command that acts only while signature help is showing. The add-in'sf1fired with the focus in the code editor, in the DEBUG CONSOLE's entry box and on nothing; it typed nothing, and Monaco's command palette, which Monaco binds to F1, did not open --- the key-down handler callspreventDefault()andstopPropagation()for F1 to F12 (specialKeyMustNotPropagate), so Monaco never sees them. With signature help showing, F1 did both things: the IDE expanded the signature help, and the add-in'sf1fired. The IDE also wrotecommand failed: "tbHelp_ToggleExpandSignatureHelp"to the DEBUG CONSOLE, a bug of its own (BUGS-TO-REPORT.md). An add-in cannot stop the built-in. - A shortcut on a key that types fires as the user types.
dtyped into the code editor, and into the DEBUG CONSOLE's entry box, went in and fired the add-in'sd. - Keys with no binding in the default keymap: Shift+F1, F4, Shift+F4, Shift+F5, Shift+F6, F7 and Shift+F12. A user can rebind any of them.
- Both handlers return at once while a modal dialog or the rename widget is open.
- Letter keys are named from
e.code(KeyDgivesd), every other key frome.key(F1givesf1). So Shift+1 arrives as{shift}!on a US layout.
Read at main.js@1002292 (toolWindowElementAddChild) and @1005960
(toolWindowElementSetProperty), and measured on BETA 983 by P3, P4 and P12, whose lane is
test/addin/panes.test.mjs.
- A tool window is part of the main document, inside an open shadow root, not an iframe.
Measured on Samples 10 and 15:
toolWindowsByIdis keyed by the second argument the add-in gaveToolWindows.Add("GlobalSearchAddInData","WaynesWindowData"), itsbodyElementis in the shadow root, and the root's host is#toolWindow<n>(#toolWindow900). A window an add-in created and has not shown is there already, and every element in it has no size. A window's content can be taller than the window: Sample 10's eleventh button had a size and a place, but its place was under the window's bottom edge, where a click lands on the resize handle. - A window's id is its identity, and a window given none shares the id
"".createToolWindow(main.js@992211) files a window under the second argument ofToolWindows.Add, andcreateToolWindowByIdreturns the window the page already has under that id, with its body emptied, rather than a new one; the add-in's newToolWindowis then bound to it, since the page answers with its number. Measured on BETA 983: P9's window given no id was under"", and the last test of panes.test.mjs opened two windows given no id and got one, titled by the second, holding the second's element and what was then added through the first's object, while the first's own element was gone. So every tool window needs an id of its own; the published ToolWindows page said to leave it out for a window that is not kept, and has an IMPORTANT now. The same rule is why a restart does no harm to a window with an id: the add-in'sAddafter the restart gets its old window back, emptied and shown again (P9). None of the shipped samples leaves the id out. - An add-in's toolbar button is
#addinButton-<id>, with the id the add-in gaveAddButton, inside#rootMenu2, and its caption as itstitle. HtmlElements.Add(id, tagName)accepts any tag. The four IDE widget tags (chartjs,monaco,listview,virtuallistview) become adivwith extra setup; every other name goes straight todocument.createElement, soiframeis not refused. A parent is found withquerySelector(":scope #"+id), so element ids must be valid CSS identifiers.- Showing a window sets its root's
displaytoblock.toolWindowSetVisiblesetsbodyElement.style.displayto"block"or"none", so a flex or grid layout an add-in puts on the root is gone the moment the window shows: the probe'sdisplay: flexreadblockafterVisible = True, and its iframe kept the default 150 px height. Sample 10 lays its root out as a flex column and gets a block. A child of the root with its owndisplay: flexandheight: 100%, under a root withheight: 100%, keeps the layout; the published ToolWindow page says so. - Property sets go straight to the DOM, as
r[a]=e.value:innerHTML,srcandsrcdocpass through unchanged. A property whose name starts withonis dropped silently, and the call still reports success --- measured (P4): the probe's.onclick = "..."raised no error, and the element had neither anonclickproperty nor attribute. The test is on the last name of the path. A step in a property path that is an array is called as a function, so DOM methods can be reached too. - An inline handler inside
innerHTMLruns as the IDE page's own script (P4). It is an attribute, not a property, so it is not dropped. Measured: an<img src='data:,' onerror='...'>set throughinnerHTMLran its handler at once, with nothing clicked, and an inlineonclickran when clicked; both sawtypeof openEditors === "object", a global of the IDE's page. That is the one way an add-in can call the page's internals; see Open decisions for where it may be used. Sample 15 already relies on it: each search result it gives its list view'saddItemis HTML with an inlineonclick='raiseEvent("onClickMatch", event, true, path, line, column)', and a click on one runs it. The event travels only from the element that carries the handler: Sample 15's[line,col]label sits beside the clickable line rather than inside it, so a click on the label reaches the handler of the whole file's entry, which opens the file's first match. Text from a file or the user must be escaped before it goes into such HTML; the published HtmlElementProperties page says so. - Events. A name the element has as a property or as
on<name>gets a realaddEventListener, and a copy of the event goes back to the add-in over the compiler's root socket, withtargetreduced to{id, value}(copyEvent). Any other name is stored as a function on the element, or on the object at the end of the property path, under that name (toolWindowElementSetPropertyCallback) --- that function is whatraiseEventcalls.raiseEventclimbsparentNodeto the first node with arootEventHandlerand callsrootEventHandler[name](event). Only three kinds of node have one: alistvieworvirtuallistviewcontainer (the list view object), the shadow root of anAddMonacoWidgetwidget (the widget's own element), and one of the IDE's dialogs. SoraiseEventfrom plain tool-window HTML throws (P12, measured):TypeError: Cannot read properties of null (reading 'rootEventHandler'), at the shadow root, whoseparentNodeisnull, and the add-in's listener is not called. The stored function can be called directly:onclick='this.parentNode.p12Event(event)'reached the listener its parent registered as"p12Event", witheventInfo.target.id=p12direct. So:AddEventListeneron elements with ids for a few controls; a list view withraiseEventin its items for a list; the direct call for HTML set throughinnerHTMLoutside a list view.
-
The external browser.
ShellExecuteWfrom the add-in DLL. Certain to work; it leaves the IDE. The IDE itself opens links withhostAppObject.Shell('cmd.exe /c start "link" "'+url+'"',1)(reported) --- a host object that only page script can reach. -
An
iframein a tool window --- it works (P3, BETA 983). Nothing refuses the tag. No file underide\sets a Content-Security-Policy --- there is noContent-Security-Policy,http-equivorframe-ancestorsin anyide\*.htm--- and neither does the compiler's HTTP header template (reported). The host DLL,bin/twinBASIC_ide_win32.dll, names no WebView2 navigation event at all, so nothing intercepts a frame's navigation. So the most expensive tier of the June draft --- whole pages inside the IDE --- is the cheapest, and the site's own navigation and search come with it. Measured with the probe lane, on pages it serves itself onlocalhost:- the frame loaded the page whose URL the add-in set as
src(the server sawsec-fetch-dest: iframe), followed a link in the page, and moved again when the add-in setsrca second time; the add-in's"load"listener heard every load; - the mouse wheel over the frame scrolled the page;
- with a wrapper's flex layout the frame filled the window below the other elements;
- keys pressed with the focus in the frame go to the page: F1 there did not fire the
add-in's
f1, and did once the focus was back in the IDE's own document; - the page's colour scheme is WebView2's, never the IDE's. The IDE sets none on its
WebView2 --- no
ColorSchemeinmain.js, noPreferredColorSchemein the host DLL --- so a page'sprefers-color-schemeis Windows' app mode by default. On this machine all three were dark, so the lane cannot tell them apart; a lab IDE whose WebView2 was told to prefer light (--blink-settings=preferredColorScheme=1added to itsWEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS) showed the framed page light, and its own page reported light, while the IDE's theme stayed dark. The site followsprefers-color-schemeunless its own toggle has stored a choice, so a page in the pane matches the IDE only when Windows' app mode happens to agree --- see Stage 3's embedded mode.
The live site works in the frame too (lab, 2026-09-24). The KeyboardShortcuts page on docs.twinbasic.com loaded in 0.9 s as a cross-site frame, with a process and a DevTools target of its own (type
iframein/json/list; the page'sPage.getFrameTreedoes not list it). It applied its own dark theme, had its search box andlunr, scrolled under the wheel, and a link to Host navigated the frame. The site, served by GitHub Pages, sends neitherX-Frame-Optionsnor a CSP (curl -I, 2026-09-24), and no page of the built site has atarget="_blank"link, so its links stay in the frame. The host DLL does nameNewWindowRequested, and what it does with a new window was not tried, since it might start a browser. A link to a site that refuses to be framed, such as GitHub, would show the browser's error page in the pane; not tried either. - the frame loaded the page whose URL the add-in set as
-
The IDE's WEBPAGE panel, a second native WebView2 whose default URL is
https://www.google.com, controlled throughhostAppObject.SetAdditionalWebview2Urland thetbWebpage_ShowPanelcommand (reported). Page internals only. -
The IDE's markdown preview, which opens
.mdfiles found under a package (viewFileAsMarkdownPreview) (reported). Our pages use kramdown extensions the preview does not know. A last resort for offline use.
Offline is harder. _site-offline/ works over file://, and a page served from
http://localhost cannot frame a file:// URL. The options are srcdoc with rewritten
links, or files under the IDE's ide\ folder. The IDE serves any file placed there (P13,
BETA 983), measured by test/addin/ideserver.test.mjs
with files put in a lane's copy of the install:
- The server is the page server,
bin\twinBASIC_win32.exe --ide=<pid>, not the compiler: it was the process listening on the page's port. The page ishttp://localhost:<port>/<passkey>/main.htmwith<base href="/<passkey>/">, the passkey a GUID, and a relative URL is a path underide\. - Fifteen files of the kinds the offline site is made of came back byte for byte: pages,
stylesheets, scripts, images and fonts, in folders two deep, a name with a space in it,
4 MB of JavaScript, and a file written after the IDE had started. A frame given the
relative
srcp13/page.htmlshowed the page, with its stylesheet and script working. - Three things differ from a real web server. A query string makes any request a 404,
so
page.html?theme=darkis not found, and Stage 3'sthemeparameter could not be a query on this route; a fragment is not sent, and does no harm..html,.json,.jpg,.woff2,.mjsand.txtcome with noContent-Type--- the browser sniffs the page and it renders --- while.htm,.css,.js,.svg,.pngand.gifget the usual types. A folder is not a page, and nothing is served without the passkey. - A page served this way is on the IDE page's own origin, so its script can reach the
IDE's internals: the framed page read
typeof parent.openEditorsas"object". Only our own pages, then, andsandboxon the frame if that matters.
So the offline site would work copied into ide\, with one cost the live site does not
have: writing into the install, which every new build replaces. Still deferred.
-
The add-in API gives the editor's text, selection and cursor, and nothing about symbols. Hence the June draft's own word extraction (Stage 4) and, for context, its own parser of the project's source.
-
The compiler knows, and names the package, the container and the kind (P5). Measured on BETA 983 by test/addin/symbols.test.mjs, which puts each question the way the IDE's own code does. Hover (
textDocument/hover,main.js@842393) returns markdown: for a procedure, its declaration, then a heading naming where it is declared, then its[Description]text, which for a VBA function is several paragraphs:Function MsgBox ( ByRef Prompt As Variant, ... ) As VbMsgBoxResult ___ ## **MsgBox** `in VBA.Interaction`The heading gives the package and the module for a function (
VBA.Interaction,VBA.Conversion,SymbolsProbe.Symbolsfor the project's own), and the package and the interface for a class's member:c.Addisin VBA._Collection, andHost.ToolWindows.Addin tbIDE.IToolWindowsV1. Those are the classes' default interfaces, not the names the documentation's pages have; hover over the class itself says*class* **Collection** ... in package VBAand lists*[default]* VBA._Collection. A variable gives its declaration,*local variable* Dim c As Collection.Debug,Debug.Printand a statement such asDimgive nothing, and a type such asLonga line about it. Over a procedure's name in its own declaration, hover gives a debug block instead,TB-DEBUG CODEGEN SIZE: [NOT-READY]; over aByValparameter of a class,String,VariantorObjectit adds a wrong note aboutOption Explicit(BUGS-TO-REPORT.md). -
Go To Definition names the package's own source.
textDocument/definition(@846297) returns one{uri, range}. ForMsgBoxit istwinbasic:/SymbolsProbe/Packages/tbIDE/Packages/VBA/Sources/Interaction.twin, the declaration's lines, and the IDE's file system opens it. Each package's own references sit under itsPackagesfolder again, so in a project that references tbIDE, VBA is in the tree twice, and definition named tbIDE's copy: the package is the name after the lastPackages/. The file is the module for a function, and for a class's member the file the interface is in (Collection.twin,ToolWindows.twin). Nothing forDebug.Print. -
Signature help and completion say the same. The completion request's
signatures[](textDocument/completion, which the code editor's intellisense sends) have adocthat starts with the same heading, for package procedures as for the project's own: P2 sawin AddinHost.Haystackin the expanded signature help.textDocument/lazyCompletiongives a completion's declaring file and line, the same as definition's. -
Only page script can ask. The call is
lspSocket.request(method, params, callback). A harness makes it over CDP; an add-in could only through an inline handler in HTML it sets (P4), which Open decisions keeps for probes.
Host.ShowMessageBoxandHost.ShowNotificationare drawn in the page, not as native dialogs, and a harness reads them and clicks them (measured on Sample 10). A message box is a.modalDialogContainerholding a.modalTitleBar(the title as a text node, then a close button), a.simpleMsgBoxwith the message and a.msgBoxButtonper button; the add-in's call returns once one is clicked. A notification's text is the.msgBoxTextof one of the fixed boxes#msgBox1to#msgBox3.- The IDE calls
alert()at 37 sites, 33 inmain.jsand 4 inmain2.js, and neverconfirm()orprompt(). Analert()blocks the renderer, so the harness records and dismisses every one (Page.javascriptDialogOpening, thenPage.handleJavaScriptDialog), whichattachIdenow does. The ones an add-in test could reach: the rename provider (alert("need to massage workspace edits here...")), Find with an invalid regular expression, and an unknown message on any of the compiler's sockets. A notification's "Copy to clipboard" link also callsalert(), but plainShowNotificationmessages hide that link. An alert that opened before the harness attached cannot be dismissed over CDP (measured): the page then answers nothing, and the harness reports it as the likely cause. The IDE's own candidates are its "IDE startup failure" alert and "Bad command line syntax.", whichlaunchIde's single argument never provokes.
- All IDE settings are in
HKCU\Software\VB and VBA Program Settings\twinBASIC_IDE:IDESettings(13 values ---GENERAL,GENERAL2,LAYOUTv2,WEBPANEL_SETTINGS,targetArchitectureMemory, the licence values, ...),ProjectState(one value per project ever opened),RecentlyOpened(21 values) andWindow(6). Every IDE a user runs shares them, and so does every IDE a harness starts. - The existing harness already leaves entries there. On 2026-09-23, 318 of the 424
ProjectStatevalues were temp projects fromexamples.batlanes,%TEMP%\tbprobeand scratchpad probes, and all 21RecentlyOpenedentries were temp projects: the user's own recent projects had been pushed out of the IDE's recent list entirely. Both were cleared by hand the same day --- 339ProjectStatevalues by then, because a harness run in another session had added 21 more in the meantime. Stage 1 fixes this for every harness tool, not only for add-in tests. - The
.twinprojassociation isHKCU\Software\Classes\.twinproj→twinBASIC.ProjectFile, and it currently points at the BETA 983twinBASIC.exe. A harvested finding said every launch re-registers it. Key timestamps say otherwise:DefaultIconandshell\open\commandwere last written when BETA 983 was installed, and a day of launches of that build did not touch them. The IDE rewrites them when its path differs, measured in item 2: read while an IDE copy in%TEMP%was running, the keys pointed into the copy, while the real install's IDE, run the same way, left them alone. So a copy points the user's association at a folder that is about to be deleted, and tb-registry.mjs puts it back (three writes). %APPDATA%\twinBASICholds the user's downloaded packages and emptyaddins\win32,addins\win64,localeandthemesfolders, and it is shared by every install. A compile session wrote nothing to it.SaveSettingfrom an add-in writes to the same tree, underVB and VBA Program Settings\<app name>, so it is shared with any installed copy of the same add-in. A test that changes an add-in-wide option changes it for the user too, which is why the add-in runner records and puts back every application a lane names (Stage 1, item 7).- WebView2 profiles:
%LOCALAPPDATA%\twinBASIC\v0for the IDE ---tbbuildreplaces it per port withWEBVIEW2_USER_DATA_FOLDER--- and%LOCALAPPDATA%\twinBASIC_WebPanel\v0for the WEBPAGE panel (reported).
Done: the June draft is folded in here, and WIP.md points here. The add-in's source goes
in add-in/ at the repository root, where the June draft put it, as an exported source
tree (Settings plus Sources/*.twin, the shape tbrun takes), so that it diffs as text
and stays outside docs/, where the publish allowlist would refuse it.
Everything after this stage is developed against it.
-
One library instead of logic inside two scripts. The code that starts the IDE, attaches, waits for the project, reads the console, watches for a compiler crash and kills the process tree is written inline in scripts/tbbuild.mjs and scripts/tbrun.mjs, and finding the IDE is written three times (
tbbuild,tbrun, scripts/lib/tb-install.mjs). Move it intoscripts/lib/, keep both scripts' behaviour and exit codes exactly, and check the move with a fullexamples.batrun (1,116 samples, ~110 s) and atbrunprobe.Done: scripts/lib/tb-ide.mjs. 14 fixture cases were run before and after --- clean, compile errors, warnings, the BUGS-TO-REPORT crash, argument errors, and
tbrunoutput and errors --- with every exit code and output line the same apart from two bug fixes, andexamples.batpassed 1,116 of 1,116 both times. The two bugs, a relative project path that never loaded andtbrun --idebuilding with a different install, are in WIP.Harness.md. Two more gaps showed up while reading the code, and both matter here, because add-in tests will hit dialogs:tbbuildcannot record analert()--- it listens forPage.javascriptDialogOpeningbut never sendsPage.enable, and CDP delivers no Page event without it --- and noRuntime.evaluatehas a timeout, so a dialog blocking the renderer would hang the wait loop rather than let it time out. Neither is tested yet. Item 5 fixes both and proves the fix with a probe that opens a dialog. -
A private IDE for every lane. The install is 91 MB (
bin37,projects30,ide15,packages7). Hardlink it into the lane's work folder, with a real, emptyaddins\win32andaddins\win64, so that a test add-in never loads into the user's IDE and two lanes never share one. Copy, rather than link, anything the IDE writes to during a session (P11), and copy everything when%TEMP%and the install are on different volumes.Done, as a copy rather than hardlinks: scripts/lib/tb-ide-copy.mjs, described in WIP.Harness.md, A private IDE for every lane. P11 came back negative --- a session writes nothing into its install --- and without
projects\the copy is 57 MB and takes 380 ms, so linking would have saved nothing worth the risk. The copy's compiler loaded no add-in where the real install's loaded Global Search, the 14 fixture cases gave identical output from it, and the real install stayed byte-identical. The work also found and closed a process leak that every harness tool had: a compiler the IDE restarts after a crash can start whiletaskkill /Twalks the tree, and survives it, holding the install's files open. The IDE now runs inside a kill-on-close job (The IDE runs inside a job), which also means a run that dies takes its IDEs with it. -
Leave the registry as it was found, for every harness tool:
- save
HKCU\Software\Classes\.twinprojandtwinBASIC.ProjectFilebefore a run, and restore them once the last lane has ended; - delete the
ProjectStatevalues the run created, and restoreRecentlyOpened. Match paths with either separator: some tools store them with forward slashes (C:/Users/.../Temp/tbprobe/...), and a backslash-only pattern missed 15 of them in the hand cleanup; - save and restore the add-in's own
SaveSettingkey; - refuse to start while
%APPDATA%\twinBASIC\addins\*holds a DLL, until P6 says whether that folder matters --- it does, and the lanes now keep out of it instead (below).
The complete answer is a separate Windows account for test runs, which only the user can create. Start with the above.
Done for
tbbuild,tbrunandcheck_examples: scripts/lib/tb-registry.mjs, described in WIP.Harness.md, What a run leaves in the registry. The 14 fixture cases, run one at a time, and a fullexamples.batrun leave the registry identical, value for value, with every output line unchanged. The work also found an IDE bug, now in BUGS-TO-REPORT.md: a recent list shorter than 21 entries gets its empty slots filled with copies of the last entry. Item 4 added a fourth thing to put back, the build target the IDE remembers for each project path, because a lane that inheritswin64builds and loads the wrong bitness.The last two bullets are done in the runner (item 7). It records the
SaveSettingkeys a lane names and puts them back. It refused to start while%APPDATA%\twinBASIC\addinsheld a DLL until P6 said the folder matters, and then that refusal became anAPPDATAof each lane's own: every IDE a lane starts, the add-in builds' included, gets<work>\appdata, and the lane checks afterwards that the IDE's add-ins folder is under it (checkAddinsRootintb-ide.mjs). So the user's add-ins never load into a test IDE, and the user need not move them out to run the tests; verified with a stand-in%APPDATA%holding the Global Search add-in, where the P6 lane's IDE loaded its own probe alone. Item 7 also corrected the recent list. The sweep was exact only on an empty list, which is what the list was when it was verified: a run that began with one entry ended with seventeen copies of it, because of the bug above, and a full list loses its oldest entry for every project a run opens. The tidy now records the whole list and puts it back as found (WIP.Harness.md, What a run leaves in the registry). - save
-
Build, then load. The add-in is a Standard DLL. In a staged copy of its tree, pin
project.buildPathto an explicit file in the lane IDE'saddins\<arch>\, the waytbrunpins its exe path: the default${SourcePath}\Build\...template has already cost a run with an invisible Save dialog, and whether the samples'${IdePath}template behaves any better is untested. Build, end that IDE, then start the same lane IDE on a test project; its compiler loads the add-in as it starts. One project per IDE, as always.Done, building into the work folder instead:
buildAddinin scripts/lib/tb-addin.mjs builds with the lane's copy into<work>\out\, andaddAddinintb-ide-copy.mjsthen puts the DLL in the copy'saddins\win32. Built straight intoaddins, a rebuild would meet the previous build loaded by the very IDE doing the building, and a loaded add-in cannot be overwritten (P8). WIP.Harness.md, Building an add-in and loading it has the rest: how the build log is read, how a build is made for win32 or win64 (win32 only until P7 was answered), and what was measured. Samples 10 and 15 both built and loaded, and the tree staging thattbrundid is now scripts/lib/tb-project.mjs, shared by both. -
Operating the IDE and reading it, as library calls over CDP:
- open a file:
fs.tree.resolvePath("twinbasic:/<Project>/Sources/<file>"), thenopenEditors.openFile(node,false,false,false,line,col), line and column counted from 1 (measured); - move the cursor or select:
window.editoris the one Monaco code editor, given the model of whichever file's tab is selected (setPosition,setSelection,getModel().getValue(); measured). Notmonaco.editor.getEditors(), which also returns editors that add-ins created (reported); - press keys:
Input.dispatchKeyEventkey-down, then key-up, with realkeyandcodevalues, less than 500 ms apart; - click: real
Input.dispatchMouseEventpresses at the element's centre. The IDE's own controls ignoreelement.click()---tbrunlearned that on#buildIcon; - ask which add-ins loaded:
loadedAddins(c)intb-ide.mjs(done for item 2), which lists a DLL that failed to load asUnknown Addin; - build the open project:
buildProject(c)intb-ide.mjs(done for item 4); - read a tool window through
toolWindowsById[<guid>].bodyElement; read the DEBUG CONSOLE's backing array, notifications and message boxes; dismiss anyalert(); notice a compiler restart or crash, astbbuild's console check already does.
Done: scripts/lib/tb-operate.mjs, with
readCrashintb-ide.mjs, described in WIP.Harness.md, Operating the IDE and reading it. Both acceptance scenarios were carried out with it by hand on a lab IDE: Sample 15 --- the toolbar button, the search typed key by key, both files' results, and a click on one match that openedHaystack.twinat line 4, column 13 --- and Sample 10 --- its tool window, the three-button message box answeredbutton2, the follow-up answeredok, a notification and a DEBUG CONSOLE line. The two gaps item 1 found are closed: every CDP call has a time limit, and the connection records and dismisses dialogs, proved with analert(); an alert already open before the harness attached is the one case it cannot handle, and it says so. Two dangers were closed on the way:launchIderefuses a DevTools port another IDE holds, since the harness would otherwise operate that IDE, and the registry tidy no longer puts back an association that pointed into the temp folder. The runner (item 7) turns the two scenarios into tests. - open a file:
-
No real side effects. The add-in's URL opener checks an environment variable (name to be chosen) and, when it is set, prints
open <url>to the DEBUG CONSOLE instead of starting a browser. On a private desktop a real browser would start where nobody can see it and outlive the run. P10 checks that the variable reaches the compiler process.Done: the variable is
TB_ADDIN_TEST, and P10 answered yes (Stage 2 has the measurement). An add-in treats it as set when it is not empty.launchIdein tb-ide.mjs sets it to1for every IDE the harness starts,tbbuild's,tbrun's andexamples.bat's included, because each of them loads whatever add-ins the user has installed, on a desktop nobody watches; a caller'senvcan set it otherwise, or leave it out with the valueundefined.openedUrls(c, { since })in tb-operate.mjs reads theopen <url>lines back, andconsoleMark(c)intb-ide.mjstakes the mark thatsincenames, so a scenario asks what was opened after the key it pressed. A line counts only when what followsopenhas no white space in it, as a URL has none, so an ordinary line that starts with the word is not read as one.PrintTextstores its text escaped (<b>as<b>), so a URL comes back exactly as printed,&included.The probe stayed in scratch. It was thirty lines:
Host_OnProjectLoadedprintingEnviron$("TB_ADDIN_TEST"), the same throughGetEnvironmentVariableW, and its own process id. What it measured matters only while the add-in depends on it, and the add-in checks it itself from Stage 4 on (increment 1 below).add-in/holds the add-in's tree and nothing else:stageProjectcopies the whole folder it is given and packs the copy, so a probe kept inside it would be packed into the add-in's project. -
A runner. Scenarios in JavaScript under
node:test, one IDE per test project, lanes by--portas today. The add-in's pure twinBASIC logic --- word extraction, lookup --- is tested without loading any add-in: a test project holds those modules and a[RunAfterBuild]runner that prints one line per case,tbrunbuilds and runs it, and the JavaScript side checks the lines. The wrapper isaddin-test.bat, outside every gate and CI for the reasonexamples.batis: it needs Windows and a twinBASIC install.Done: scripts/addin_test.mjs, with the lanes in test/addin/ and what a scenario gets in scripts/lib/tb-lane.mjs, described in WIP.Harness.md, The add-in test runner. A lane is one scenario file, run in a process of its own with its own port and copy of the install; the runner owns the registry, the add-ins' saved settings included, and checks it afterwards. Ctrl+C and a lane timeout both end the lanes and still put the registry back. The pure-logic tests wait for Stage 4, which writes that logic. They belong in a lane too, built and run in the lane's own copy rather than by
tbrun: atbrunstarted under the runner leaves its registry entries to the runner, which sweeps only the lanes' folders.Two library changes came out of it:
clickwaits up to five seconds for its target, since a list view draws a row a moment after the row is in its data, andremoveTreeretries a delete that an ending IDE still blocks, since on Node 24rmSync's ownmaxRetriesdoes not.
Done when the harness operates two shipped samples end to end, and the user's registry is unchanged afterwards:
- Sample 10: toolbar button, then its tool window, then a message box.
- Sample 15: type a search, see the results, click one, and the right file opens at the right line.
Met on 2026-09-24, BETA 983: both scenarios pass under addin-test.bat, and a
comparison of the whole registry around the run, IDESettings included through hashes,
was identical.
Most probes are a small add-in plus a scenario. P5, P11 and P13 need only CDP and the file system. P14, planned as a question for upstream, was answered from the compiler's own code and then measured. Record every answer in this file with the build number it was measured on.
Stage 2 is done (2026-09-25, BETA 983): every question is answered, and the eight lanes pass together with the two sample lanes, ten in all, in 2 minutes 21 seconds at two at a time.
A probe whose answer something else rests on becomes a lane: its add-in in
test/addin/probes/<name>/, its scenario beside the others, listed in lanes.mjs, with each
test asserting what the build did. A later build that behaves differently then fails the
run, and the failure says what to update. keys.test.mjs (P1,
P2) is the first --- the KeyboardShortcuts page's NOTE and two entries in BUGS-TO-REPORT.md
rest on it --- and panes.test.mjs (P3, P4, P12) the second,
under the NOTEs on the HtmlElement, HtmlElementProperties, HtmlElements and ToolWindow
pages. symbols.test.mjs (P5) holds up Stage 4's context
and an entry in BUGS-TO-REPORT.md; it needs no add-in, only the project in
probes/symbols. ideserver.test.mjs (P13) holds up the
offline route, and appdata.test.mjs (P6) the lanes' own
APPDATA and the FAQ's answer on where add-ins go. arch.test.mjs
(P7) holds up the Add Ins page's account of which folder loads when, and the win64 builds
buildAddin now makes; reload.test.mjs (P8, P9) the account
of a compiler restart on the tbIDE package page and the ToolWindows page; and
entry.test.mjs (P14) the entry point's NOTE on the tbIDE
package page. P9 turned up the shared id "" of windows given none, and the test of it
went in the panes lane, with the rest of what a tool window does. A probe that settles a
question once, as P10's did, stays in scratch; so did the two P3 checks that need the
network or a changed WebView2, the live site in the frame and the colour scheme with
WebView2 preferring light.
| # | Question | What it decides |
|---|---|---|
| P1 | Do {ctrl} and {alt} add-in shortcuts ever fire? Register {ctrl}{shift}d, {alt}f, {shift}d, d and f1, and press each. Answered, BETA 983: no. d, {shift}d, f1 and {shift}f1 fire; {ctrl}{shift}d, {ctrl}d and {alt}f fire only when the same key was pressed on its own less than 500 ms before. Queued in BUGS-TO-REPORT.md; the KeyboardShortcuts page has a NOTE. |
the bug report; which key the add-in uses; the NOTE on the KeyboardShortcuts page |
| P2 | Does the add-in's f1 fire with focus in the code editor, and what happens with signature help showing? Answered, BETA 983: yes. It fires with the focus in the code editor, in the DEBUG CONSOLE and on nothing, and types nothing. With signature help showing, the IDE expands or collapses it as well, and logs command failed: "tbHelp_ToggleExpandSignatureHelp". |
F1 or another key --- F1 |
| P3 | Does an iframe of a documentation page load and navigate inside a tool window? Size, scrolling, theme. Answered, BETA 983: yes. It loads, follows its own links, moves when the add-in sets src, scrolls, and fills the window under a wrapper's flex layout; the live site works too. Keys in the frame never reach the add-in, and the page's colour scheme is Windows', not the IDE's. |
how pages are shown --- in the pane |
| P4 | Does innerHTML render, and do inline handlers in it run page script? Answered, BETA 983: yes, and yes. It renders, and its inline handlers run as the IDE page's own script --- an <img>'s onerror with nothing clicked --- with its globals in reach. A property whose name starts with on is dropped, and the add-in hears no error. |
how summaries are drawn; whether the page-internals route exists --- it does |
| P5 | What does hover return for MsgBox, Collection.Add, ToolWindows.Add and a symbol declared in the project? What does definition return for a package symbol? Answered, BETA 983: hover gives the declaration, which says the kind, then a heading naming where it is declared: in VBA.Interaction, in VBA._Collection, in tbIDE.IToolWindowsV1, in SymbolsProbe.Symbols --- a class's members by its default interface, not by the class's name. Definition gives the declaration in the package's own source, .../Packages/VBA/Sources/Interaction.twin, which the IDE opens. Nothing for Debug.Print or a statement. Only page script can ask. |
compiler-assisted context is possible; which route is Stage 4, increment 3 |
| P6 | Does the compiler also load add-ins from %APPDATA%\twinBASIC\addins\<arch>? This needs a DLL placed there for a moment, and the user's own IDE would load it too --- ask before running it. Answered, BETA 983: yes, from addins\win32 there and not from addins itself. The page expands %APPDATA% in the IDE's environment and sends the folder, and the compiler loads from what it is sent. Measured with APPDATA pointed at a folder of the lane's own, so no DLL went in the user's. |
harness isolation --- every lane IDE gets an APPDATA of its own |
| P7 | Which bitness does the compiler start in, and does switching the build target restart it in the other one and load the other addins folder? Answered, BETA 983: yes, and yes. The target a project opens in picks the compiler --- win32 when the IDE remembers none, twinBASIC_win64_noDEP.exe for a project remembered as win64 --- and each compiler loads the folders of its own bitness alone, the install's and %APPDATA%'s. Switching the target of an open project restarts the compiler in the other bitness, and the new one loads the other folders: a 64-bit build of the probe in each win64 folder loaded, and ran 64-bit, once the project was switched to win64, and the 32-bit ones again after a switch back. |
building and testing both bitnesses --- buildAddin builds either, and a shipped add-in needs both |
| P8 | Is a loaded add-in DLL locked against being overwritten? Answered, BETA 983: yes. While its IDE runs, overwriting fails (EBUSY) and deleting fails (EPERM), though renaming works; the hold outlasts the compiler's exit by a few tens of milliseconds. The P9 lane checks all three again. |
the rebuild loop --- the DLL is built outside addins, and copied in once the IDE has ended, or renamed aside first (P9) |
| P9 | Does a compiler restart reload add-ins from disk? Answered, BETA 983: yes. A restart --- the restart button, a switch of build target, or the IDE's own after a crash --- removes every add-in's buttons and shortcuts, leaves its windows showing (currently unavailable), kills the old compiler, so that no Class_Terminate runs, and starts a compiler that loads whatever file is in the folders then. With the loaded DLL renamed aside, a restart loaded nothing; with a new build put in its place, the next restart loaded it, about a second after the click, and it got its old windows back by their ids. |
a rebuild loop without restarting the IDE --- it works: rename aside, copy in, restart |
| P10 | Does an environment variable set by the harness reach the add-in (Environ$)? Answered, BETA 983: yes, through the launcher, the IDE and the compiler the IDE starts. With TB_ADDIN_TEST=1 in launchIde's environment, Environ$ and GetEnvironmentVariableW both returned 1 in the add-in, and a compiler started by the restart button returned it too; left out, both said it was unset. WEBVIEW2_USER_DATA_FOLDER, which launchIde always sets, arrived with the lane's port in it. |
the side-effect switch |
| P11 | Does the IDE write into its own install folder during a session? Answered, BETA 983: no. A compile, a compiler crash and a tbrun build-and-run left all 233 files byte-identical, mtimes included. |
hardlinks or copies --- copies, for safety, at 380 ms |
| P12 | Does raiseEvent from plain tool-window HTML throw? Answered, BETA 983: yes --- TypeError: Cannot read properties of null (reading 'rootEventHandler'), and the listener is not called. An inline handler that calls the listener AddEventListener stored on its parent, this.parentNode.<name>(event), reaches the add-in. |
how the pane's events are written |
| P13 | Does the compiler's HTTP server serve any file placed under ide\? Answered, BETA 983: yes, and it is the page server, twinBASIC_win32.exe --ide=<pid>, not the compiler. Any file, byte for byte, below the page's passkey path, including one written after the IDE started; a frame with a relative src shows it on the IDE page's own origin. A query string makes a 404, and .html has no Content-Type. |
an offline route --- it exists (Offline) |
| P14 | What do tbCreateCompilerAddin_v2 and _v3 expect? Answered, BETA 983: what tbCreateCompilerAddin does. The loader looks for the plain name, then _v2, then _v3, and calls whichever it finds with the Host alone and asks the result for IAddInV1. The names are version stamps: the linker exports a function named tbCreateCompilerAddin as tbCreateCompilerAddin_v3 alone, and an IDE that knows none of a DLL's names refuses it as compiled for a newer version of the twinBASIC IDE, as a patched _v4 was. |
nothing in the design --- the add-in declares tbCreateCompilerAddin as the package says; the tbIDE page has a NOTE |
The June draft hand-wrote about 80 entries, and some of its URLs were wrong: Collection is
at /tB/Modules/Collection, not under VBRUN. The index is generated instead.
- From the documentation: every page's
permalink:exactly as written. Folder-style pages keep their trailing slash (/tB/Packages/VB/CheckBox/), single-file pages do not (/tB/Packages/tbIDE/ToolWindows), so a URL is copied, never assembled. A member is either a page or a heading.Collection.Addhas its own page,/tB/Modules/Collection/Add;ToolWindows.Addis a heading on its class page, and its URL uses the id tbdocs gives that heading (### Addgives#add), read from the build and never computed a second time. Canonical URLs only, never aredirect_fromalias. The documentation defines two kinds of alias the index must know: the$,BandWvariants share the base page (LenB→/tB/Modules/Strings/Len), and attributes are/tB/Core/Attributes#<name in lowercase>. - From the packages: the public API from an
exportof the shipped packages --- the export and its cache already exist in builder/census_attributes.mjs. That supplies each symbol's package, container and kind, and lists public symbols that have no page, which measures documentation coverage as a side effect. It must also supply each class's default interface, because that is what the compiler names a class's members by (P5): hover saysin VBA._CollectionforCollection.Addandin tbIDE.IToolWindowsV1forToolWindows.Add, and a lookup that takes the compiler's word has to map_CollectiontoCollection. - Output: one JSON file published with the site, emitted the way
assets/js/search-data.jsonis, at a stable URL so that an installed add-in can fetch a newer index. A copy is also built into the add-in, for when the site cannot be reached. - Gates: every URL is under
/tB/and resolves; an entry that disappears fails the build, the waybuilder/page-baseline.jsontreats a fall in the page count --- which is what catches a reworded heading breaking a member's anchor; retiring an entry follows the rules in Permanent Links. - An embedded mode for pages: P3 says the theme needs one. The pages work in the pane
unmodified, but they follow Windows' app mode, not the IDE's theme, and the add-in cannot
reach into the frame to change that: the live site is cross-site, so neither its document
nor its storage is the IDE page's. The site can take the theme from its URL instead: a
theme=dark|lightquery parameter, read by the no-flash snippet inrenderHead(builder/template.mjs) and set asdata-themewithout being stored, so a reader's own choice on the site is left as it is. Hiding the header and navigation is a separate, optional question, untested. Deferred until the add-in works, at least in part (decided 2026-09-25): it changes what the published pages do, and it needs a test that the parameter keeps working. It serves the live site only: the IDE's own server answers any URL with a query string with a 404 (P13), so the offline route would need the theme some other way.
The June data model stands:
{ "name": "Add", "package": "tbIDE", "container": "ToolWindows", "kind": "method",
"url": "/tB/Packages/tbIDE/ToolWindows#add" }name--- as written in source; matched without regard to case.package--- the owning package, ornullfor language keywords and statements.container--- the class or module, ornullfor a top-level symbol.kind---keyword,statement,function,class,module,enum,enumvalue,control,property,methodorevent.url--- the path below the documentation root.
Names that belong to more than one page, to test lookup against:
FileSystem--- the VBA module and the tbIDE class.Line--- the VB control, the CustomControls style class, and theLine Inputstatement.App--- the VB page and the AppGlobalClassObject_Apppages.Name--- the statement,AddIn.Name,ToolWindow.Name,HtmlElement.Name, and the controls'Nameproperties.Close--- the statement,ToolWindow.Close,Editor.CloseandProject.Close.Timer--- the VB control (/tB/Packages/VB/Timer/) and the VBA function (/tB/Modules/DateTime/Timer).Add---Collection.Add,ToolWindows.Add,HtmlElements.AddandKeyboardShortcuts.Add.Item---Collection.Item, andItemon six tbIDE classes (Editors,Folder,HtmlElements,HtmlElementProperties,HtmlEventProperties,Toolbars).
The generated index produces the complete list.
Everything above except the embedded mode. The index is the symbolIndex task of the
build, in builder/symbols.mjs, and it publishes
/tB/symbols.json --- under
/tB/ because the file is part of the same contract as the URLs in it. 5,536 entries at
4,086 distinct URLs; 782 KB, 52 KB gzipped, under 100 ms of the build. Permanent Links
documents the format for any reader; Building
and WIP.Build.md the build side.
The entries come from the pages, and the packages annotate them. Joining the other way
round was tried first, and the packages declare far more than the pages give an anchor to:
of the 2,368 public members of VB's documented classes, 173 have no page or heading, and
of WinNativeCommonCtls' 738, 294 --- inherited members that pages name in a sentence
(DTPicker "inherits ... Anchors, Dock, Font ...") or not at all. An index built from the
packages would carry those as page-less names, and would put the pages' own layout in the
wrong place. So a page is read for what it documents, by these
rules, in builder/symbols.mjs's header: its title and its first
heading's comma list name a type or member; a page under a type's page in the nav is one
of its members; a heading on a type's page whose every part is an identifier and one a
member is that member's, as is any heading under Properties, Methods or Events; an
inherited member documented on its declaring type's page has that URL from every type that
inherits it (CodeEditor.Close is Editor#close); a page filed under one module and
declared in another belongs to the declarer (VBA's Array is under Information and
declared in _HiddenModule); a $ form shares its base's URL; and a Core page's title
gives its statement and keywords (Do...Loop: Do, and Loop as a keyword). Every
package page gives at least one entry; a build names any that stops.
What the packages add, from builder/package-api.json, which
scripts/build_package_api.mjs writes from the packages of
an install, through the export census_attributes.mjs already made (now
scripts/lib/tb-packages.mjs, shared, and the census's output
checked identical) and a declaration scanner,
scripts/lib/twin-api.mjs. It is committed, like
inter-metrics.json, because CI has no install. 1,379 types in 16 exports, 255 KB. Four
facts about the packages shaped it:
- A package is known by its project name, not its folder's. TwinBasicAssertions is
Assert, all three CEF builds arecefPackage(their declared APIs are identical, and the generator checks that they stay so), and AppGlobalClassObject isAppGlobalClassProject. That is what code writes and hover says, so the index'spackageis the project name, and itspackagesmap gives each project's name on the pages. - Visibility cannot be read off one declaration. CEF's
CefLogSeverityis aPublic Enuminside aPrivate Module, documented and used; CustomControls'Bordersis aPrivate Classa control'sBordersproperty returns; andCheckBoxdeclares almost nothing itself, its members being onPrivate Class CheckBoxBaseCtland four classes above it; and VB'sClipboardis a public CoClass whose members are onPrivate Interface _Clipboard. So every declared type is kept and marked, a non-public one keeping its members only when a public type exposes them, by inheritance or as the interface a public CoClass is built on. [Hidden]is not "absent".Module [_HiddenModule]carries it, and its members are globals.- The default-interface map comes from CoClasses:
interfacesmapsVBA._CollectiontoVBA.CollectionandtbIDE.IToolWindowsV1totbIDE.ToolWindows, 37 in all, which is P5's hover answer turned into a lookup.
Two pages needed symbols:, a new optional frontmatter key naming what a page documents
when its title cannot: the (Default) module is _HiddenModule, and the comparison
operators page documents =, <> and four more. One page was wrong: IntegerDivide's
first heading, # \ and \= operators, rendered "\ and = operators" because \= is a
markdown escape. It is \\= now; its id did not change.
The gates. builder/symbol-baseline.json lists every published URL, and a build that
loses one fails --- the case it exists for is a reworded member heading, which moves an
anchor an installed add-in still holds. scripts/check_symbol_index.mjs, a seventh
test.bat gate, asserts each derivation rule, each scanner trap and the guard's refusals
on fixtures. Every URL resolves by construction, and tB/symbols.json is in the online
tree's index, so --check-audit-index sees it written. The build's summary gives the
count of public symbols no page documents, 602 today, and --symbol-gaps <file> lists
them: 173 VB and 294 WinNativeCommonCtls members inherited and named only in prose, 48
WinNativeCommonCtls types --- enumerations and structures declared in its controls' base
classes and its public ...Consts modules --- 39 members of VBA's _HiddenModule and
Interaction, and a tail of a few each.
The URL is settled (2026-09-25): /tB/symbols.json is part of the Permanent Links
contract from here on.
Not done, and why:
- The embedded mode above waits until the add-in works, at least in part (decided 2026-09-25): it changes what the published pages do.
- Keywords with no page of their own ---
ElseIf,Until,Step,To,In,ByVal,ByRef,Optional,As--- are in the index only where a page's title gives them. Adding one is asymbols:line on the page that explains it, which is a content decision per keyword. Deferred with the next item (2026-09-25). - Data types ---
Long,String,LongPtr--- have their page at/Reference/Data-Types, outside/tB/, so the index cannot carry them without breaking its own rule. Giving that page a/tB/permalink, with the old one inredirect_from:, would. Deferred (2026-09-25). - The offline tree has no copy of the index, since the offline route is deferred.
- Hover over a VB control's member is unmeasured. P5 measured CoClasses; for a
Classsuch asCheckBox, whose members are inherited from private classes, hover may name the declaring base class, which theinterfacesmap does not cover. A P5 case settles it when increment 3 needs it.
Each increment is finished with its scenarios. Worked on by hand, a new build replaces the loaded one without ending the IDE: rename the loaded DLL aside, put the new build in its place, and click the compiler's restart button (P9). The lanes need not: a restart saves a harness about a second against opening a new IDE.
-
F1 to a page. A toolbar button; the key; the name under the cursor; index lookup; open the page in the browser or the pane. A miss says
No help for '<name>'throughShowNotification.The key is F1, as planned: P1 and P2 do not rule it out. It fires wherever the focus is in the IDE's window. The one overlap is signature help: while it shows, F1 also expands or collapses it, and the cursor is then inside a call's parentheses, often on an argument rather than the procedure. If that proves a nuisance, Shift+F1 has no binding of its own. Never a key with
{ctrl}or{alt}while the P1 bug stands.The URL opener honours the test switch. It calls
ShellExecuteW, except whileEnviron$("TB_ADDIN_TEST")is not empty: then it printsopen <url>to the DEBUG CONSOLE and starts nothing (Stage 1, item 6). When the add-in loads it prints whether the switch is on, and every scenario checks that line before it presses anything. An IDE build that stopped passing the variable on to the compiler then fails the run, instead of starting a browser on the private desktop. -
The help pane. Search over the index, results, and a page view --- an iframe, since P3 passed, laid out inside a wrapper element because showing the window resets its root's
display. The results are a list view withraiseEventin their HTML, as in Sample 15, sinceraiseEventworks nowhere else (P12); escape every name that goes into that HTML. Theme: readHost.Themes.ActiveThemeNameGroupat start, handleHost_OnChangedThemeafter, and pass a light or a dark stylesheet toToolWindow.ApplyCssfor the pane's own controls, and the theme in the page's URL once the site reads one (Stage 3). F1 pressed while the focus is in the page goes to the page, not to the add-in, so a lookup from there goes through the pane's own search box.The pane has an id of its own, never none: every window given no id is the same window. And it outlives the add-in. Every compiler restart, which every switch of build target is, ends the add-in and loads a new instance, which gets the same window back, emptied (P9). So the pane is built in
Host_OnProjectLoaded, every time, and the page it was showing is lost unless the add-in keeps its URL outside its own process ---Project.SaveMetaData, say --- and reads it back there. -
Context: which
Add? P5 says the compiler can answer it: hover namesc.AddasVBA._Collection's andHost.ToolWindows.AddastbIDE.IToolWindowsV1's, where a line scanner would have to find the declaration ofcand the type ofHost.ToolWindowsfirst. But only page script can ask, so the route is the choice: through the public API once upstream adds a call, with the add-in's own parser below until then; or throughlspSocketfrom an inline handler (P4), which the open decision on page internals rules out for the shipped add-in. Either way the answer names an interface, which the index maps to its class (Stage 3). -
Later: hover help through
CodeEditor.AddMonacoWidgetafter a pause (the cost of adding and removing widgets is not measured); offering only the packages the project references; the[Description]connection; offline use.
Lookup, from June:
- A selection is looked up exactly as selected.
- Otherwise take the dotted name under the cursor, and try it as
container.namefirst. - With several matches, a language keyword wins a bare name; otherwise offer the choice.
Word extraction, from June. Given the line and the cursor's column:
- If the character at the cursor is not
A-Z,a-z,0-9or_, there is no word. A.at the cursor sits between two segments, so it also gives no word. - Extend left over those characters and
., to take the whole dotted chain on the left. - Extend right over those characters without
., stopping at the end of the current segment.
With the cursor on Add in Set w = Host.ToolWindows.Add(name, id) this gives
Host.ToolWindows.Add. Not handled in the first version: string literals and comments (the
lookup misses; File.ReadText(CommentsToWhitespace) can blank comments out if needed),
lines joined with _, and numbers (they miss the index). Whether GetSelectionInfo counts
columns from 1 is untested.
Settings: per project through Project.SaveMetaData / LoadMetaData, add-in-wide
through SaveSetting / GetSetting --- which is the shared registry tree, so Stage 1's
registry restore has to include it.
The add-in's own parser, the June draft's Phase 4, kept as the fallback for increment 3:
- On
Host_OnProjectLoaded, which runs again after every compiler restart, in a new instance of the add-in (P9), go through the virtual file system withFor Each--- neverCountandItem, because the IDE is multi-threaded (WIP.tbIDE.md) --- read each source file withFile.ReadText, and record declarations only:Module,Class,Interface,EnumandTypeheaders; member headers with theirAstypes;Dim,Private,Public,StaticandConstwith theirAstypes;Implements. - Before a lookup, read again only the files that changed (
File.IsDirty, or a hash). - Resolve
x.Addby finding the declaration ofxin scope and taking its declared type. - Work line by line and skip anything not understood; it is not a compiler. The census
notes in WIP.Harness.md list the
traps a line scanner meets in this language: attributes that span lines, comma-separated
attribute lists, escaped identifiers such as
[_HiddenModule], comments between attribute groups, andType As Longfields. - June proposed scanning on a background thread. Measure the scan first; a second thread inside the IDE's process is the riskiest part of the whole design.
- Build both bitnesses, which
buildAddindoes (P7). Add a documentation page under docs/IDE/AddIns/. - Distribution is upstream's decision: the community add-ins list, or bundled with the IDE.
- Take to upstream, with the probe results as evidence: the shortcut bug; a call to open a
URL; a way to ask the compiler about the symbol at a position, whose answer hover already
has (P5); tool windows given no id sharing one window. The shortcut bug and the windows
are in BUGS-TO-REPORT.md. What
_v2and_v3are for no longer needs asking: P14 answered it.
Recommended, and not yet confirmed:
- Page internals are for probes only. The shipped add-in uses the public API and
ShellExecuteW, and whatever the API lacks is requested upstream. P4 showed the route exists: any inline handler can reach the page's globals,openEditors,lspSocketandhostAppObjectamong them.raiseEventin a list view's items is the exception, since the IDE's own samples use it that way and it is the only way a list view reports a click. P5 raises what the rule costs: throughlspSocketthe add-in would know the package and interface of any name under the cursor today (Stage 4, increment 3). - The site reads a
themequery parameter, so that a page in the help pane can match the IDE's theme (Stage 3, after P3). Deferred until the add-in works, at least in part (2026-09-25); decided then, not before. - Isolation starts with restoring the registry (Stage 1, item 3), and a private
APPDATAfor every lane IDE (P6). A separate Windows account for test runs comes only if that proves not to be enough.
- It said add-ins could only be tested by hand. Stage 1 exists to change that.
- It hand-wrote the symbol index. Its URL-scheme section named paths that do not exist
(
/tB/Reference/Statements/<keyword>,/tB/Reference/Procedures-and-Functions/<function>), although its examples used the real/tB/Core/<Statement>and/tB/Modules/<Module>/<Symbol>. Of its five "VBRUN classes" onlyPropertyBagis in VBRUN:Collectionis/tB/Modules/Collection,Erris/tB/Modules/ErrObject,Appis/tB/Packages/VB/App/, and the reference has noDictionarypage at all. Its name-collision table has the same package errors, and two more: it callsTimera statement (it is the VB control and a VBA function), and it counts the IDE'slistviewwidget tag, which is a tag name, not a symbol. The collisions themselves are real, and Stage 3 lists them. - It asked whether F1 was free. It is not; see Keyboard
shortcuts. It also took the key strings on the published page on
trust, and
{ctrl}and{alt}do not work for add-ins. - It said a tool window could not show a web page. Nothing refuses an
iframe, and P3 showed one working, so the order of its three tiers is reversed. - Its code skeletons are not kept. They were never compiled; Stage 4 writes the modules against the compiler, with tests.
Stage 1 is built, so the rules for testing add-ins bind every session and are in
WIP.md, Driving the twinBASIC compiler: no test
add-in in the real install's addins\ or in %APPDATA%\twinBASIC\addins\, no real browser
from a test, every SaveSetting application named in lanes.mjs, IDEs ended by pid, and
one project per IDE.