The console line
The Log panel ends in one line of JavaScript, the devtools console's idea applied to PlumeSQL's own state: what is on screen (the result, the tab, its connection, the script's outline and parse tree, the connection's object dictionary) is there as plain globals, with completion, and an expression's answer is a Log entry like every other message.
The shape of the API
One rule holds everywhere: a property is a view, a method is an action. Anything that can be named is a property with completion (results[123].first_name, db.public.actor.columns.first_name, conn.state); a method exists only where something is done (reveal(), ddl(), fetch(), copy()), and no method takes a name as a string when that name could be a property. The types the editor completes with are generated from the same snapshot the values come from, so completion and runtime never disagree: the result's columns become a row interface, every schema of the dictionary an interface of its objects.
The scope is a view, never the app's state: values are copies and read-only proxies, so an expression cannot move anything except through the few methods named. A connection's password is never on the page and cannot be here either.
The globals
They form ONE hierarchy, the way the app owns things: the tab owns its result, its connection, its outline and its parse tree (tab.results, tab.connection, tab.outline, tab.ast), the connection owns its object dictionary (tab.connection.db). The globals below are aliases of these, the short way to type them (results IS tab.results, conn is tab.connection, db is conn.db; the parse tree has no alias; extensions, the loaded extensions, stands on its own, being the app's rather than the tab's); completion from tab. reaches everything. The links are not enumerable, so tab prints as its facts and tab.copy() never drags a result along. The shape is ready for tabs and connections (every open tab, every connection) when they come.
results(aliasrows): the result on screen, its DATA: an array of row objects addressable by column name (results[123].first_name,results.filter(r => Number(r.amount) > 100); cells are the server's text,nullfor NULL).results.lengthis the loaded window (the first 5,000 rows, gathered page by page),results.countthe rows stored (every row of the result, unless the spill disk fell under its reserve or the run was cut short) andresults.totalthe whole result's count either way;await results.fetch(from, n)reads a page of rows from the server. A row past the window is still a row: a bareresults[1000000]on the line fetches and prints it (one indexed read of the spool), andresults[1000000].reveal()scrolls the grid to it (the grid fetches the block). A row past the STORED ones exists only after the disk reserve or a cut short run, and thenresults[124000000].reveal()answers with WHY, naming the folder and the free space seen, with the reserve setting one click away (aResults Settingsbutton on the row), so what to free is said rather than guessed.results.first,results.middleandresults.lastare the first, middle and last stored row, views exactly likeresults[i](nullon an empty result). The array's own methods (filter,map,slice) walk the loaded window alone.results.columns.first_nameis{ name, type, index, typeOid, tableOid, attnum, editable }(the OIDs and attribute number where the column comes from a real table column, null for an expression), and a column writes the predicates for the grid's filter:.contains(x),.eq(x),.isNull(),.notNull().results[i].indexis the row's index.results.grid: the grid SHOWING the result, every action on it in one place, the data staying onresults.results.grid.filter(pred, …)is the server-side filter the column header sets (one per column, over the whole result, so the header, the stats line and the console show one state), DECLARATIVE: what is passed is what stands,results.grid.filter(results.columns.first_name.contains('foster'), results.columns.amount.eq('100')), andresults.grid.filter()with nothing clears it. (results.filter(r => …)is JavaScript'sArray.filterover the loaded window, answered as a table, the grid untouched.)results.grid.filtersis what stands,results.grid.countthe rows the grid shows after the filter (nullwhile the server counts);results.grid.reveal(i)scrolls to a row and selects it;results.grid.hide(col, …)andresults.grid.show(col, …)are the header menu's column switches (a column or its name;show()with nothing shows every hidden one), hidden columns staying out of Copy all and Save All; printed, the grid is one line of where it stands;results.grid.select()is the grid's own Select All andresults.grid.copy()its Copy all: the same clipboard road, the same format settings (results.copyFormat,results.copyHeaders) and the sameresults.copyMaxRowscap the grid's menu applies, so a copy of a hundred million rows is the first ten thousand, said so in the Log; the call itself answers with nothing, the clipboard's "Copied ..." line being its record (copy(results)is something else: the loaded window as JSON). When only a view shows the result (-- @view … only), the data is there and the grid is not:results.grid.visibleis false and every action answers with that, never an error. The filter operators are the grid's own, so a new one there is a new one here.tab: the active tab:id,name,path,root,kind,dirty,size,savedAt,connectionId,statements, andlastRun(at,durationMs,running,results[]with each statement's command, row count, columns and error; printed, a table of the results).tab.run()is the Run button andtab.save()the Save command, both for the active tab (tab.reveal()makes it so);tab.copy()copies the script's text. Printed, a line and a table of its facts.conn: the tab's connection:id,name,host,port,database,user,url(never with the password, which is not on the page),origin,readOnly,state(no connection,not connected,connected,disconnected, the app's own words),tx(idle,in transaction,transaction failed),pid,connectedAt,ageMs,serverVersion,searchPath.conn.connect()dials the tab's session now (the first run would) andconn.disconnect()closes it, the tab's own Disconnect: a call from the line is the user's own gesture;conn.reveal()walks the Connections tree to it;conn.copy()copies the URL. Printed, a line of its state and a table of its facts.outline: the statements the Outline shows, each{ n, kind, verb, dangerous, head, label, line, lines, from, to, text, refs, tables, reveal(), copy() }:kindis the run guard's verdict (read,data,schema; exact from libpg-query when the statement parses, the keyword walk otherwise),dangerouswhat the guard flags,refsevery relation or function the statement names,tablesthose the dictionary knows as relations. Printed, a table (number, kind, statement, tables, line), sooutlineanswers what a long script touches and where it writes before it runs:outline.filter(s => s.kind !== 'read'),outline.filter(s => s.dangerous),outline.flatMap(s => s.tables);outline[results.statement]is the statement the result on screen came from,outline[2].reveal()puts the cursor on the third,select()selects it,run()runs it the editor's own way (the selection, then Run with its guard: the console never runs SQL around the tab). Printed, one statement is a line of what it is, its text as code and its facts under it. The raw parse tree istab.ast(statements[i],error,copy()the JSON; printed, the statements' top nodes), reachable for tooling, not a global: nothing everyday is done with a raw tree.db: the connection's object dictionary (docs/OBJECTINDEX.md).db.<schema>.<name>is an object with a SHORT top level, what one does with a table rather than everything the index knows:name,schema,type,comment;columns(by name:{ name, num, type, def, comment, ref }, printed as a table),usedByanddeps(paths, printed as a table of objects),facts(everything else:oid,path,params,routine,extension,timescale,citus; printed key / value);ddl, its DDL from the snapshot, andscript.*, the tree's Script menu as properties (script.select,script.count,script.insert,script.insert.returning,script.insert.conflictUpdate,script.drop... exactly the menu's list for that object type, from the same objectscripts.ts, so a new template there is a new property here). A script is a VALUE, not an action: printed, it is the Log's code block;await x.script.select.sqlis its text;.copy()puts it on the clipboard and.script()opens it in a new unsaved script tab on the object's connection (the menu's Script and Copy Script). Never a run(): the console is not a way to run SQL, the tab is.browse()opens a table or a view in a result document tab (Browse Data),reveal()walks the Objects tree to its row,copy()puts its DDL on the clipboard (what printing shows; the facts are on the page already).db.tables,db.viewsanddb.functionsresolve a bare name the way SQL would, along the connection's search path;db.schemaslists the schemas,db.searchPaththe path. A routine is under its bare name (the first overload stands for it). An object named like no identifier is reached the JavaScript way,db.public["Order Items"], and completion writes the quotes. PRINTED, a list of objects is a table and one object is its DDL:db.tablesdraws the tables by search path (name, schema, type, columns, comment; the user's schemas first, the catalog's last),db.publica schema's objects,dbanddb.schemasthe schemas with their counts, anddb.tables.actorthe CREATE TABLE as the Log's own code block (clamped, More, Copy code), headed by what it is (the value itself, and$_, stays the object). A table in the Log showslog.tableRowsrows at first (fifty) with Show more and Show all under it, psql's pager idea; an answer keeps at most a thousand rows and says how many more there were. The rule behind it holds for everything the line prints: Markdown and code over a JSON dump; JSON is the last resort for a shape nothing else fits. An object registers how it prints (printsAsin console.ts) and the line asks there first.copy(x): any value to the clipboard (through the one clipboard road, so the Log records it), and every object the line builds has a.copy()of its own with completion. CELLS copy in the Copy Format setting the grid's own menu uses (tab or comma separated, JSON array or objects, headers per the switch), so a copy reads the same from the grid and from the line: a row (results.last.copy()), rows picked in JavaScript (results.filter(r => …).copy(),results.slice(0, 5).copy(), a fetched page) and the result itself (results.copy(), the firstresults.copyMaxRowsrows like the grid's Copy All;results.grid.copy()stays the grid as painted, the filtered view and the painted columns). What is not cells copies what it IS: a tab its script's text, a connection its URL (never the password), an outline row its statement's text, a dictionary object its DDL, a column its facts as JSON,tab.astthe tree as JSON. The call answers with nothing, the clipboard's "Copied ..." line being its record.save()exists on the result and its grid alone, because a save is the server writing the spool into a file the desktop shell's dialog named (no row passes through the page, no cap):results.save()writes every stored row in the Save Format setting,results.grid.save()is the menu's Save All (the filtered view, the painted columns); rows picked in JavaScript are copied, not saved, and theirsave()says so. In the browser there is no dialog andsave()says that too.$_: the last answer (DevTools' own name, so it needs no help line).help(orhelp()): the INDEX of the globals, a short table with the topic to read in its last column; a topic is a property with completion,help.results,help.results.columns,help.results.grid,help.tab,help.conn,help.outline,help.ast,help.db(consoleand$_have no topic, being JavaScript's and DevTools' own;copy(x)explains itself in the index's row), and prints its own section alone (what it is, every member on a line of its own with a note, one thing to try, the topics under it named), read top down in the Log's width; the hover on a topic says what it is before anything prints. Never one long text and never a name as a string. The topics (HELP_TOPICSinweb/src/lib/console.ts) and this section are updated in the SAME change as any global they describe: a new property, method or rename lands in both, or the line answers with a stale word.extensions: every extension PlumeSQL has loaded, one property per file stem: the grid extensions (.plumesql.jsfiles and unsaved drafts,extensions.sanddance), the query extensions (.plumesql.sql,extensions.current_activity) and the command extensions (.plumesql.run,extensions.psql). Printed, a table: name, kind, store path, how many rules and which views (grid), the@for/@queryscope in words, whether a grid extension applies to the result on screen, and where a query or command one is pinned. A query or command extension is{ kind, name, path, root, description, pinned }and does what its pin does:extensions.current_activity.run()asks it on the active tab's connection (a command runs in a terminal tab), the answer in a preview tab;open()opens the file;pin("statusbar")andunpin()edit its@toolbarline exactly as the Pin to menu does (no place means the title bar's start). A grid extension is{ name, path, root, scope, rules[], views[], appliesHere }(a rule:index,kindformatter / computed / companion,matchin words,add,priority; a view:tab,inputskeys) and answers the question the Log and Problems never do:extensions.sanddance.explain()prints why it does or does not shape the result on screen, in steps: the road that forces it (a-- @extensionor-- @viewline above the query or in the header, the grid menu's apply once) or its scope's verdict with the reason (@fornot covering this file,@querymatching neither the text nor the source table, no marker at all), then a table of its rules (the columns each matches, itswhenguard, what it formats or which rule outranked it) and of its views.open()opens the file,apply()is the grid menu's "apply here once",await extensions.reload()the header's Reload.results.rulesis the other end of the same answer: which rule shapes which column of the result, as the grid's planner decided it. Both read the functions the grid and the view tabs read (app.rulesFor,app.viewsFor, one scope verdict), so the explanation is the grid's own truth.extensions.runningis what the extensions have running now, a table: every view alive (a mounted iframe with its library, its rows and, while drawing, the thread's attention:drawing for 12 sordrawn) and every@refreshtimer ticking, each row with akill();extensions.sanddance.kill()tears down every live view of that extension in every tab. A killed view shows a calm stopped face and is drawn again by Reload, a click on its tab or Run in its dialog; a held timer starts again with Refresh. This reaches what holds memory or the GPU while the page still runs (a view paging 300,000 rows, a chart animating); a render that blocks the thread outright cannot be reached by anything on the page, the console included. The Log's own road to the same stop: the entry a hung render writes after 90 seconds ("the render gave no answer") carries a Stop the view button.console: the line's own.console.log(...),info,debug,warn,errorwrite Log rows (the page's devtools console is not where you are looking),console.table(x)draws a Markdown table like an answer does; the call itself answers with nothing, its line being the record.
An action answers with a short note as the row's message (row 5 revealed in the grid, the help text), one row per call (copy() answers with nothing of its own: the clipboard's "Copied ..." line is its record); only an expression that genuinely yields undefined prints undefined.
A promise the line evaluates to is awaited on its own: results.fetch(0, 5) or db.public.actor.ddl() answers with the rows or the DDL, never with Promise {}. await is needed only INSIDE an expression, where JavaScript itself cannot wait for you: (await results.fetch(0, 5)).length. A line that is not an expression (a const, a loop) runs as statements, and its answer is what it returns.
The line
Enter runs (when the suggest widget is not taking it); Up / Down recall the history, kept per workspace in the session (the last hundred, most recent last); a pasted newline becomes a space. The answer lands in the Log as a row whose expression is drawn as the prompt line it was typed on (› help, dim, DevTools' way: no block and no button, the row's Copy takes it with the answer; the entry's expr; a code answer, an object's DDL, is the entry's code, the Log's clamped block), and whose message is the value (a string quoted, a number bare; a row or an array of rows as a TABLE (up to a thousand rows, log.tableRows of them shown at first, Show more for the rest), written as Markdown and drawn by the Markdown viewer, with Copy as Markdown under it for a document or a ticket; any other object or array summarized, its JSON in the detail, capped so a million rows never reach the log file); an evaluation error is a red row that starts dismissed, answered where it was typed, never a takeover. An answer is always brought into view: the panel scrolls to its tail on every line run, however far up it was scrolled (a background entry never moves the scroll). Source console, so the source filter narrows to the conversation.
psql on the line
A line that starts with a backslash is one of the editor's psql commands (docs/ARCHITECTURE.md, "psql meta-commands in the editor"): \dt, \d film, \df, \sf f, \sv v, \conninfo, \? and the rest of the list web/src/lib/psqlmeta.ts keeps. The line answers them from the same road the editor uses (the dictionary, the snapshot's files, the connection's facts; nothing is asked of the server), as the Log's code block in psql's own shape, the command as the prompt line. Completion is the editor's: the commands while the backslash is typed, the argument's family from the dictionary, and, in both the editor and the line, the commands on a bare word too (type dt, accept \dt, the backslash is added), psql's muscle memory without the backslash first. A backslash that is no command answers with that and \?. help.psql lists them.
Completion
The line completes with the same TypeScript service the extension editors use, over types generated from the screen, but FOCUSED: at the top level the list is the console's own globals and the JavaScript a one-line expression reaches for (Math, JSON, Date, await, new ...), never the DOM (alert, addEventListener, AbortController: there is no page to talk to from here) and never a module or declaration keyword; inside a member access (results.first.) the type's own members stand, and no word of the line itself is ever offered. The line has a language of its own (plumesql-console, the JavaScript grammar) so the filter never touches an extension editor; the allowlist is consoleCompletionAllow in web/src/lib/console.ts, fed by CONSOLE_GLOBALS, the one list of the line's globals (the scope binds by it too).
The line and the editor
The line is a Monaco editor of its own, and Monaco keeps ONE keybinding service for every editor on the page: a keybinding added to the line without a context of its own would fire in the SQL editor too (Enter and the arrows did, once). The line's Enter, Up and Down are therefore bound under a context key set on that editor alone, so they resolve there and nowhere else; the SQL editor's keys are untouched whether the line is mounted or not. Which pane has the keyboard is what the app already shows: the focused zone's edge and the focus ring, and the line's own accent rule while it has the caret.
What it is not
Not a way to run SQL (that is Run, with its guard, its parser and its Log conventions), not a handle on the app's internals (nothing here mutates state), and not a second extension API: a row here has the same shape a rule's row has in a .plumesql.js, by column name.