The contract for how glass draws Claude Code's transcript on the terminal. Code follows this file; undocumented deviations are bugs.
One file, one state. This is the consolidated contract as of 0.5.0 (2026-10-11): the pass-by-pass history of 0.1 to 0.4.25 is folded into the sections it amended, and a rule that was tried and rejected stays as a short note beside the rule that replaced it, with its date, so nobody re-proposes it. The previous file is in git history (commit e4cd939, 0.4.25). New rules for 0.5.0 are marked (0.5.0); an item marked unverified live has a mounted test and awaits a session.
Glass is a Claude Code mod (function-hooks plugin) that redraws the terminal transcript the way a desktop client draws a chat: colored shell commands in prose, a Bottom line card, content-fit code cards, borderless tables, gutter marks beside paragraphs that need the reader, a tool tree with a concrete trunk under each turn, painted tool bodies, a background band for live subagents. The owner is its first user; anyone on a dark terminal is its audience. Engine floor: Claude Code 2.1.295 (0.5.0 reads APIs that arrived in 2.1.288 to 2.1.295; see Engine floor under 0.5.0).
claude plugin validate .— the manifest and the module's source as the engine reads them.npx -p typescript tsc -p .— type-check, after the engine has laid.claude-plugin/typeson a live load (a barenpx tscfetches an unrelated package).claude plugin test .—tests/*.test.tsin the engine's sandbox.LC_ALL=C grep -rn '[^ -~]' hooks/— must print nothing (BSD grep has no-P).scripts/check.sh— all of the above plus the manifest version check; CI runs it.claude --plugin-dir /path/to/glass— a session on the working tree. Disable the installed copy first: two enabled plugins with one name and the engine skips one's hooks (2.1.296 notes it in/plugin).- Release: bump
versionin.claude-plugin/plugin.jsonand.claude-plugin/marketplace.jsontogether, commit,claude plugin tag ., push,claude plugin update glass@glass, restart.
hooks/
register.ts hooks module: every on(...), the atoms, module records
chrome.ts turn chrome: user row, tree rows, bodies, diff card, band
render.ts reply blocks: paragraphs, lists, fences, tables, cards
markdown.ts the streaming markdown parser (blocks, inline spans)
shell.ts command tokenizer, prose command spans, vocabulary
paths.ts path and URL detection
prose.ts gutter-mark heuristics, write intent
highlight.ts fence highlighter
output.ts painted tool output lines
palette.ts Palette type, the five palettes, paletteNamed
theme.ts (0.5.0) palette -> engine theme slots, theme JSON
width.ts cellWidth, clip helpers (the one ruler)
glyphs.ts every non-ASCII glyph, by code point
hooks.json { "modules": ["./register.ts"] }
types/index.d.ts the $.state contract (PluginState.glass)
tests/ parse.test.ts, chrome.test.ts, (0.5.0) theme.test.ts, demo.test.ts
scripts/check.sh every check in one run
scripts/paint.ts (0.10.0) the demo painter: a tree to SGR rows, rows to HTML
scripts/show.ts the fixtures before and after, painted in the terminal
scripts/readme-shots.ts (0.10.0) the README's palette screenshots, headless Chrome
scripts/demo-dump.sh the text dumps under demo/
demo/ (0.5.0) before/ and after/ text dumps for review
docs/shots/ (0.10.0) one demo turn per palette, 100 columns on the owner's ground
.claude-plugin/ plugin.json, marketplace.json, types/ (engine-written, ignored)
- TypeScript, no build step: the engine loads
hooks/register.tsas is. - Every non-ASCII glyph is built with
String.fromCodePointinhooks/glyphs.ts. No literal or\uescape outside ASCII anywhere inhooks/, comments included. (The tool path that writes files has dropped private-use glyphs before.) - One width ruler:
cellWidth()inhooks/width.ts, mirroring the engine'sBun.stringWidth(s, { ambiguousIsNarrow: true }), Bun 1.3's drift included (marks and invisible formats are nothing, a VS16 lifts a text-default symbol to two, a jamo tail is nothing, Indic conjuncts sum per code point, the wide table gains Tangut and the enclosed ideographic block). Never code-point length. - Loader rules learned live: a helper that takes
$is a function declared at the top of the module, never a closure insideregister; a streaming event's hook isasync function*; aBoxwith ahoverstyle carries akey, or the validator refuses the tree and the engine draws its own row (this hid the event rows for two demos); aButtonnever sits inside aText; props are set conditionally, never passed asundefined. - A tree that would be refused reverts the whole message to the engine's
renderer, so: reply text is normalized on entry (CRLF to LF, C0 controls
other than tab and newline removed); Text and Code strings stay under
10000 characters (a longer run goes as several strings in one Text);
Linkonly getshttps:orhttp://localhosthrefs. Known limit: a reply tree past the engine's 100,000 serialized characters is refused whole and drawn by the engine. - Render hooks for tree rows (
ToolUse,ToolResult,ToolGroup) read no atom: a read in a render hook subscribes the row, and every write then redraws every row of the turn, which stranded duplicate rows above the viewport (2026-10-03). They read module records written by the hooks (call -> turn, call -> wall time, folded turns, /expand);/fold,/unfold,/expand,/collapsecall$.ui.invalidate('ui.render'). A hot reload empties the records until the next turn. The user row's dots line and theTurnDurationrow subscribe (one row each per turn). - A finished reply's parse is kept (the newest 64 by text), so
/fold, resizes and palette changes do not parse every reply again. Links, URLs and code words are matched in place. - Commit subjects name the version and the rule, as the log does:
0.4.25: a loaded skill's result sits on the trunk.
- Measure
M = columns - 2, full width, recomputed per draw: the reply runs as wide as the engine's own tool output, so nothing reads as a hole on the right. (A 100-column cap was tried and rejected on a 190-column terminal, 2026-10-02. The engine'smaxProseWidthsetting of 2.1.282 is not honored, by this decision; revisit only if the owner asks.) - Rhythm:
marginTop: 1on every block except the first of a text block and any text block right under a heading (a fence, a table or a Bottom line under a heading keeps its blank: a tint or a second bold row glued to the heading read as one crowded block, 2026-10-11). NevermarginBottom, never spacer boxes. The first block of a reply keepsmarginTop: 1. No reply bullet: the assistant header carries it. - Tint only on the two cards, code fences and the Bottom line (
blockBg,= 1.2:1 over the terminal bg). Cards hug their content: width = widest row plus padding, floor 60 cells, cap M. Inline code has no tint.
- Four text steps:
text(prose, the terminal's own foreground; not aPalettekey, by design) and the palette keysmeta(times, counts, durations, args of a done row),faint(connectors, folds, hints, the fence header),bold. NodimColorin a reply: it lands on a different step per terminal theme. - Never
wrap: 'truncate*'in the reply body. A width mistake must wrap, never lose text. Stated exceptions: tree-row subjects, output lines and diagram rows, each named where it lives. - Hierarchy rule, as Empryo's: bright for the live thing, muted one step for
the done thing, faint for scaffolding, saturated color only on status
marks. No key reminders in the transcript (
ctrl+o,shift+tab): the hint line, the group rows, the Message row and the diff fold note all gave theirs up (0.5.3 for the last two).
- Plain text default color, with these prose passes:
- Command spans in prose paint only with evidence: a known command word
followed by a flag, path, string, number or operator, or preceded by a
runner prefix (
Ran,$).git push --follow-tagspaints;git statusin a sentence,make sure,go aheaddo not. For command words that are also plain English (sleep,code,time,open,find...) a bare number is not evidence. Stop words and a three-bare-word budget end a span. (proseSpansinhooks/shell.ts.) - Paths anywhere: rooted (
/,./,../,~/), a known extension by any case, a dotfile, a conventional name (Makefile,Dockerfile), router segments in balanced brackets (app/[id]/page.tsx), inpath(color only, no underline: the underline is the link's mark); a:lineor:line:colsuffix innum. Paths under the session's directory draw relative. URLs (https,ssh://,git@,www.) inurlunderlined. (hooks/paths.ts.) - A single quote opens a string only at a word's start, and an apostrophe
between two letters never closes one (2026-10-03:
diff card's framepainteddiffas a command and the rest as a string).
- Command spans in prose paint only with evidence: a known command word
followed by a flag, path, string, number or operator, or preceded by a
runner prefix (
- A paragraph opening with
PrivateorPrivatelyis Claude's own planning note: drawn whole, italic, inprivate. - A paragraph that needs the reader gets a gutter mark:
▎inmark(each palette'swarn: it asks the reader to look, so it is notok's "done", and apart from the quote and callout bars) in the two cells other blocks leave blank. Needs the reader = a question mark in prose, an imperative opener, or an attention phrase (the tables inhooks/prose.ts). A?inside backticks or a link target never counts. Never on list items, headings, code or cards. Off for the whole turn when the prompt asked for writing (write,draft, ... among its first six words). **bold**bold inbold.*italic*italic.~~strike~~strikethrough.- Links:
httpsandhttp://localhostbecomeLinkwith the label inurlunderlined; any other href draws as that styled text only.draws as the link its alt names, the bang gone; a link title stays out of the text. - Inline code is color only, as Claude Desktop and the Codex TUI draw it.
Contents, in order: shell spans when
shellSpans()recognizes a known command head (each token in its kind color); else a path inpath; elsecode. (Tinted pills with padding and Nerd Font caps were tried and rejected as noise, 2026-10-02.) - A U+FE0F after a text-default symbol (U+26A0 and kin) is dropped: the terminal draws the emoji form 2 cells while the engine counts 1.
- Streaming: unclosed
`,**,*,[stay literal. Code colors apply on the closing backtick.
- Paragraph:
Box width Mholding one wrapping Text. - Headings: H1 bold
heading; H2 bold, one step fromheadingtowardmeta; H3 and deeper boldheading3. No rules, no#. (A trailing rule was dropped with the full-width measure: a 188-cell rule is heavy.) - Lists: markers
•(top),◦(nested),☐,☑;☑inok, the rest inbullet. Ordered numbers inorderedNum, right-aligned to the widest marker at their depth, so9.and10.end on one column. Nesting is the rank of an item's indent among the indents seen so far (1. athen- bis one level down, as CommonMark reads it;floor(spaces / 2)put a four-space child two levels down). A child's marker sits under its parent's text column, so under1. Installthe◦lands under theI(0.5.2;depth*2hung it off the period). Items run to the measure. Zero rows between items, except one row each side of a card under an item: text touching a tint read as a glitch. A fence indented under an item is the item's own block: a card under the item's text, in from the marker, as wide as the text at most. A fence at the margin ends the list. - Fence: a
blockBgcard,paddingX 1, sized to its widest line (floor 60, cap M). Tabs expand to four spaces (the terminal skips a tab's cells without the tint). A blank line is one space so its row draws. Header row only from nine lines:faintuppercase language left,faintN linesright; afaintgutter from line 1 once over 8 lines. The body is glass's own highlighter (hooks/highlight.ts): keywordscodeKw, callscodeFn, typescodeType, stringscodeStr, numbers and constantscodeNum, commentscomment(0.5.3: keywords by language family, since one shared set painted Python'srange(10)and TypeScript'svalues(x)as keywords; a Rust lifetime'akeepscodeStrbut ends at its word, so it never swallows the line to the next quote; a YAML word before a colon is a key incodeFn, while a JSON key is a string and keepscodeStr, the owner's orange); shell fences throughshellSpansin the prose keys, so a command reads the same in a fence and in prose, a comment-only line incomment; prose fences (md,txt,rst,adoc...) paint nothing.fenceShapedecides diagram, gutter, header and width once, for the card and the quote bar alike, so the two cannot drift. A diagram draws as typed: a fence with no language (ortext,txt,ascii,diagram) whose non-empty rows are at least half arrow, box-drawing, block or shape glyphs, or their ASCII stand-ins (+--,|--,-->,<==, a row boxed in|, a connector row), has no header and no gutter, and its rows cut at the card edge (truncate-end). One- dash arrows and a diffstat's+++---never count. (The engine'sCodepaints with its own theme, rejected 2026-10-03; itsMarkdownleaf was tried next and dropped for the same reason.) - Table: no vertical borders. Bold
boldheader, onerulerow of─segments joined by two spaces, body rows joined by two spaces, cells padded bycellWidth. Numeric columns (a~,<,>or≈, a sign, a currency mark, digits, then a percent,xor×, or a unit of up to four letters with a per-unit tail,4.5MB/s; empty cells and the placeholders-,–,—,n/a,?,…ignored, since one dash flipped a whole column left) right-align with their header unless the separator names a side; a bare---names none. is a space. A table wider than its measure squeezes its widest column a cell at a time, down to 4, a squeezed cell cut in the middle; only a table too wide even then falls back to the engine'sMarkdownwith the raw lines. The separator must match the header's cell count; a bare---under a line with|is a rule. A table start interrupts a paragraph. Streaming reflows widths per committed row; jitter accepted. - Quote: the prose in the prose color (a
metastep, as Claude Desktop and GitHub mute a quote, was tried and turned down 2026-10-11: the owner found it flat), aquoteBar▎on every row of the quote (the row count from the same wrapping the engine applies, fence headers counted only when drawn, wrapped code lines counted, a squeezed table counted as glass draws it, an over-wide table estimated as raw html is), one space, the inner blocks atM - 2with a blank row between them, no tint. (A dim▌drawn once per inner block was too thick and vanished on wrapped rows, 2026-10-02. Box has no single-side border, so the bar is a glyph column.) - Bottom line card: detection is monotonic. Once the head line is
**Bottom line**,**Bottom line**:or**Bottom line:**the block is a card for the rest of the stream. Every following line, or the items of a list right after a title-only card, is a row;Verified:,Issue:andFix:take their label and color, any other row a blank 8-cell label. The boldcalloutBartitle sits above the card as a plain line; only the labelled rows are inside the tinted box (title inside was the first design, moved out 2026-10-02). Card =Box backgroundColor blockBgsized to its longest row (floor 60, cap M); acalloutBar▎on every row. Compact: no padding rows, no blank rows (both tried, too bulky). Labels padded to 8 then two spaces; row text incalloutText. - Rule:
─repeated to min(M, 60) inrule. - Raw html: the engine's
Markdown. - (0.5.0) Summary reply: an
AssistantMessagewhose props carryisSummaryis a compaction summary. Glass draws it as any reply and ends it with one row· summaryinfaint,marginTop 0, at the prose indent, as the engine's own faint mark reads. Never a card, never passed back to the engine (the row would lose glass's styling). The flag is read-only: the hook passes it on untouched.
The transcript drawn as the Empryo v3 desktop draws a turn, inside the
engine's row order, which glass cannot change: user row, then tool rows
and reply blocks interleaved as the model made them, then the
TurnDuration row, then task-notification rows.
- The turn boundary is one titled rule:
◆ You · 3:20 PM ───infaintto the measure, a blank row above, none below.◆andYouinaccentUser(a neutral bold name was tried and turned down, 2026-10-11: the owner likes the colored names), the time inmeta: theprompt.submitclock for that text (map text -> time, newest wins; no match, no time). The prompt wraps at M, indented 2. - The assistant header
◉ Claude · 1:14 PMis the tail of this tree after one blank row:◉and the name inaccent, the time inmeta(hours unpadded, as theprompt.submitclock draws them) fromturn.start's clock, read from state so the row redraws when the turn begins. Drawn only once a turn has started for this prompt. - The dots line, under the header, indented 2: one dot per main-loop call
in call order, grouped by consecutive tool with a space between groups,
okgreen,✗inerrfor a failed call, hollowmetawhile running; past 16 calls the first 12 dots and+Ninfaint. Then two spaces and the summary: while liveBash ×3 · Read ×2and· N failedinerr; once done17 actions · 3 edits · 2 failedinmeta, every count kept. Drawn only for turns glass saw start (prompts[].turnId). A call's dot and its tree row share a hover scope (glass:<tool_use_id>): the pointer on either lights both withrowHover; the dot keeps its own color (an inverted dot was a red block that matched nothing, 2026-10-04). The dots are sibling Texts in a Box row: a Text nested in a Text follows a hover group but cannot heat it. - (0.5.0, gated) Two plain Buttons return to the dots line's right end
once the live press retest passes (see 0.5.0, Buttons):
▸/▾folds and unfolds this turn's tree,⧉ Copycopies the turn'sanswerwith$.ui.copy. Bothplain,dimColor(a Button has no color prop; this is the onedimColorglass draws), lit by hover, with akey, in aflexShrink 0box at the row's right end.labelonly, no children, so the tree validates on engines before 2.1.295. Copy appears once the turn is done. The dots line carries the tree's right inset, so the buttons end where the durations end (0.5.2)./fold,/unfoldstay as the keyboard route. If the retest fails they stay out and this bullet is history. (Both were drawn in 0.2 and 0.3, never received a press, and were removed 2026-10-03 and 2026-10-04.) - Rows with other origins (
task-notification,peer, ...) see Events below;isExpandedfalse passes to the engine.
- On demand only (a finished turn folded by itself at first, and with no
working chevron the tree was a one-way door, 2026-10-03). Every turn's
tree stays open.
foldedTurnsholds the turn ids/foldtucked away (every finished turn at that moment);/unfoldempties it; a new turn leaves it atturn.start. A folded turn'sToolUse,ToolGroupandToolResultrows drawdisplay: none; rows of calls glass never recorded stay visible. The engine owns transcript folding of old turns (HISTORY) and ctrl+o.
- Every main-loop tool row is one line of a tree:
├─infaint, a status mark, the tool name, a subject, and a right-aligned duration.├─ ✓ Bash git log --oneline -1 · 3 lines 0.8s(The last call drew└─at first; dropped: knowing the last call needs a subscription to the turn.) - Status mark:
✓inok(ametatick, so only the red✗would carry color, was tried and turned down 2026-10-11: the owner likes the green),✗inerr(errored or interrupted),○inmetawhile running. - Tool name in
tool, bold while running, plain once done. An MCP tool (mcp__<server>__<tool>) draws as its server infaint, a space, then the tool name intool: the server dropsplugin_<plugin>_,claude_ai_and a trailing_MCP, reads lowercase with hyphens; a run of underscores draws as one; underscores stay (the name matches permission rules and logs, owner's choice over spaces, 2026-10-07). A palette hue on the server was turned down as a second colored word (0.4.24). - Subject while running: Bash tokenized with the head trusted, flowing on
one line (one command per line was tried and reverted); paths in
path; URLs inurl; other arguments dim after a comma. Once done a Bash command stays tokenized and every other subject drops tofaint. The subject never wraps:truncate-endis allowed here alone, since ctrl+o shows the whole call. Only the subject gives way: the connector and mark sit in a box that never shrinks (rowHead) on every row kind (a long subject once squeezed the mark's cell, 2026-10-05). - Right column: the call's wall time from
tool.call(clock aroundawait next(e), keyed bytool_use_id, main loop only),0.8s,12s,1m 04s,2h 05m, infaintonce done, the step the done subject sits on (every finished row's right lane is one color: tree, server tool and event rows alike); none under 100 ms; Bash adds· N linesinfaintonly when the body folds past the three painted lines (0.5.3: a body shown whole is its own count).Box row justifyContent space-between, the right TextflexShrink 0. While the call runs the right column is aRasterkeyedclock, 7 cells, the elapsed time inmeta: one ticker,$.clock.every(1000), blits every running call's clock by itstool_use_id, no redraw, no state write; it starts at the first live call and cancels itself once nothing runs. (A status line under the prompt said what ran; removed 0.4.6, noise.) - Hover: the row
Boxcarrieshover: { backgroundColor: rowHover }and akey. - Air: every tree row draws one row above itself holding only the trunk
│infaint, except a bare row that continues a run of the same bodiless tool (Read) with no prose between (turn.steptext chunks mark prose). Tree rows and their bodies sit 2 cells in from each edge (TREE_INSET), so the right column ends where the prose measure ends.
- The trunk is concrete: every body row under a tool row carries
│infaintdown a 3-cell column, the content under the mark (the engine's⎿and its collapsed body left the line in pieces, 2026-10-03). - Every Bash result is glass's, drawn under its
ToolUserow from the row's ownoutput(calls run in parallel drew noToolResult); theToolResultrow then draws nothing. Stdout and stderr painted for the first 3 lines (commands with evidence, paths and URLs, error, warning and success words, numbers and durations, check and cross marks, git status codes; unpainted text inmeta), each line clipped to one row (truncate-end; ctrl+o has them whole), the rest… +N linesinfaint. A line is cut to 1000 characters and loses its control characters first (a minified JSON line got the whole tree refused); a carriage return starts the line over, so a progress bar shows its last pass. A failed command the same way with its error words inerr. Nothing printed: onefaint(No output)line. The engine keeps only interrupted results and raw escape-coded output. Trade accepted: a long result's ctrl+o expansion is no longer the engine's collapsed body. (Rewritingoutputwith SGR codes was tried first: the engine strips them.) - A failed call other than Bash draws its reason's first line in
erron the trunk, the rest afaint… +N lines, in place of the engine'sError editing fileand its corner bracket. The[Image #N]row under an image prompt is still the engine's. - A backgrounded Agent's or Bash command's result is one trunked
faintline,running in the background · ↓ to manage. - A loaded skill's result is one trunked
faintline,skill loaded, with the engine'sN tools allowedand model after middots where it gives them; a forked skill draws as an Agent's launch. Unverified live (0.4.25). - A finished agent is one row:
✓ Agent <description>with its duration infaint. The foldedMessage from @<type>row of a subagent this session spawned drawsdisplay: none(the finished row says the same); a peer's stays and takesprivate.
structuredPatchdrawn by glass in place of the engine's panel. A roundedfaintframe on the trunk column (marginLeft TREE_INSET), the file named in its top border,╭─ SPEC.md +1 -1 ───╮(Created,Deletedbefore the path when so), drawn row by row by glass, no Box border. Every code line breaks at the card's room by the engine's ruler, mid-word as delta does, so no row wraps and every row carries its own two edges; the text Box istruncate-endas a guard only. (An absolute Text over a Box border was clipped or covered the first code row: the engine never lets a child paint the border row. A title as the first row inside the frame read plain. 2026-10-03.) Sized to the hunks (floor 60, capcolumns - 2 * TREE_INSET),paddingX 1.- One number column in
faint(metaon a tinted row): the new number on a kept or added line, the old on a removed one.+inokon anaddBgrow,-inerron adelBgrow; the tints are not green and red but the band tint and a dim plum, so marker and color carry the meaning for colorblind readers (owner's pick, 2026-10-03). Code painted by the highlighter with the language from the path; tabs expand to four spaces; one context line before a hunk's first change and one after its last, context between changes stays, numbers keep counting; an ellipsis row between hunks. Past 200 rows the rest folds to… +N lines. - A Write that created its file (
type: 'create') draws its wholecontentas added lines. Rows of a group the engine unfolded raise noToolResult, so theToolUsehook draws the card under such a row. - A Bash result whose command rewrote files (
bashEditDiff) is drawn whole by glass: the body as a short one is, folded past 3 lines, then per file one row of air and the card; a file with no hunks keeps a headerUpdated <path> +a -b(verbmeta, pathpath, countsokanderr);moreFilesends it as… +N more files. - (0.5.0) Colors: the card's tints come from the active theme's
diffAddedanddiffRemoved, its+and-marks and the header's counts from the theme'ssuccessanderror(0.5.3: thediff*Wordslots are the engine's word-level backgrounds, not text colors, so the marks never read them), wherever the engine resolves a theme key, so the card matches the engine's own diff panels under any theme. Under a glass theme (below) those slots are the palette'saddBg,delBg,ok,err, so the owner's colorblind-safe pick holds. Under the stock dark theme the card goes green and red with the engine's panels: that is the trade of following the theme, accepted 2026-10-11.
- The engine's folded run of reads and searches draws one tree row, all
faint, the mark▸inmeta(◉is the Claude header's alone): the tools with counts intool, then each subject once (a Bash call by its command, clipped to 60 cells). Each MCP server takes a line of its own under one hover, named once at its head, the built-in tools on a line of their own, first-seen order; a live group'sN running...sits on the lines with a running call, and the row ends in…. A group with any errored call opens on its own and adds· N failedinerr; a group with edits adds· N editsinfaint(0.5.2;[1 edit]was the one bracketed count in the chrome)./expandopens every group,/collapsefolds them again. No per-group button (the density pass, 2026-10-03; the press retest of 0.5.0 does not bring it back).ctrl+o to expandis not written (it never opened a group live).
- Tree rows after the turn, same geometry:
├─ ✓ Agent <description> 1m 12swhen the text opensAgent "<description>" finished(the name intool, the description inmeta), else the text's first line inmeta;✓inokunlesstask.statusreads as failed, then✗inerr; duration fromtask.durationMs, none under 100 ms. The hover key is the message'srequestId.isExpandedtrue passes to the engine; the notification's remaining lines then hang off the trunk as a Bash body does, one row per line inmeta, clipped (0.5.2; a body with no trunk sat off the column).
- The engine's
Baked for 12sline drawsdisplay: none(owner's request, 2026-10-03): the dots line carries the counts; cost and tokens stay inturnsfor later. (A footer line with time, cost, tokens andReview,Till pass,Fork,Copyactions was 0.3; the actions never received a press,Forkhad no confirmed command, and the line went.) - (0.5.0) The row is the one per-turn engine row after the reply, so it
carries the turn's server tool rows (below) when the turn had any, and
stays
display: noneotherwise. It finds its turn bydurationMs, as the footer did; this is the one subscribed row besides the dots line.
turn.step's result listsserverToolUses: calls the API ran inside the request (the advisor is one). Notool.callfires, no engine row exists, so glass records them on the turn (turns[].serverTools:id,name,startedAt,endedAt) at the main loop's step result, and draws them on theTurnDurationrow as tree rows, after the reply:├─ ✓ advisor ran by the API 4.2s. Connectorfaint, mark✓inmeta(the engine ran nothing, so it is notok's done), name intool, the note infaint, the durationendedAt - startedAtinfaintas on every finished row, none whenendedAtis absent. Each row keeps its air row above. Not counted in the dots line: the dots count calls glass can vouch for. A subagent's server tools are not drawn. The shape ofinputis not read.
Spinnerdraws<word>… · N actionswhilemodeistool-use, else the engine's line with the suffix untouched. OneText,meta.
- The engine's hint loses its key reminders,
(shift+tab to cycle)and· ← for agents. What stays:-- INSERT --inaccentUser, then the permission mode by meaning. Live, the engine draws its own mode pill first, then·, then glass's text; the owner accepted INSERT after the mode (2026-10-04). A hint with nothing to drop (? for shortcuts,esc to interrupt) keeps the engine's line and its live pills. The owner's status line is a separate script (statusLine) and out of reach. - (0.5.0) The mode word's color is the theme's: plan
planMode, accepting editsautoAccept, auto and bypasswarning, elsemeta. (It was the palette'saccent,ok,warn; under a glass theme those slots carry the same palette colors, so nothing moves for a glass-theme user, and a rose-pine user sees their own mode colors.)
- Shown while any subagent runs; passed to the engine whenever
hasSurvey. The band is one site for every plugin, so glass always callsnext(e): with no agent running it returns what the plugins beneath drew, and with agents its frame draws above that. - A rounded frame the band wide in
accent, the model column inaccenttoo, the faces inwarn(a faint frame with ametamodel column was tried and turned down, 2026-10-11: the owner likes the colored band), drawn with arc and rule glyphs since a border cannot carry a title: top edge╭─ ◌ background · N ▾ ─── 1m 25s ─╮, one row per agent│ ◆ (●‿●) name model · effort stage tokens file │in fixed columns (20, 22, 14, 8, the rest),+N more, bottom edge╰─ They outlive this turn ─── /tasks ─╯(the/taskshint only when$.command.list()has it). The face smiles while the agent thinks and lowers its eyes while it runs a tool. Stage is the last tool the agent called (MCP names as tree rows draw them), orthinking…. Tokens are the sum of the agent'sturn.stepusages read off each stop chunk; effort is the step's. The chevron (akeyofband:toggle,plain,dimColor, lit by hover, as the dots line's buttons are) closes the frame to one strip. It sits in a keyed Box (band:chevron): a hover with no keyed Box around it makes the engine refuse the whole tree and draw nothing, which is why the band was invisible from 0.5.2 to 0.5.3 (found in the debug log, 2026-10-12). Per-agent cost stays out: no price table in the API. Agent identity:agent.spawngivesdescription,subagentType,model;agentIdfrom awaitingnext(e); removed at that agent'sturn.complete. - Sized to
bodyColumns, neverviewport.columns, four cells short so the engine's[-]mark sits beside the top-right arc. Fits from 84 columns; narrower, the file column goes first, then the model, the name, the tokens and the stage give up cells; the closed strip drops its oldest faces. The file column draws relative to the session's directory and cuts in the middle, keeping the file name. - (0.10.0) A
viewButton at the right end of every agent row (akeyofband:view:<agentId>on the Button and its Box,plain,dimColor, lit by hover, as the chevron) puts that agent's transcript in the main view through$.ui.view({ agentId }); the site'se.props.view.agentIdnames the agent in view, whose row drawsback($.ui.view({})) and its name inaccent. The button's five cells come off the file column. The call is spelled at the call site through a cast of$, as the notification's is: the validator refuses$.uibound to a value, and an engine without the method (before 2.1.300) throws into a toast that names the version. A press the engine declines ({ deny }: the agent is gone, the view moved, no view here) toastsglass: <reason>.
$.ui.notify(text, { title: 'glass' })fires only for what the engine stays silent on; the engine's own idle notification already covers a finished turn, and no API tells a mod whether the terminal is focused. Two cases:- A background agent finished after its turn: an agent's
turn.completearriving while no main-loop turn is live, read from theliveTurnatom (0.5.1: module memory reset on a mid-turn reload and fired this inside a live turn; state survives the reload). Text:<description> · <duration>(description clipped to 60 cells). - A turn ended with failures: at the main loop's
turn.completewithlive.failed > 0. Text:N calls failed · <tool of the last failed call>.
- A background agent finished after its turn: an agent's
- When
isSentis false the text goes to$.ui.toastinstead. The call is guarded (typeof $.ui.notify === 'function') so an older engine toasts. userConfig.notify(boolean, default true) turns both off; it follows/configlive through aconfig.sethook, as the palette does.- Glass never notifies from a render hook or a timer, and never on hearing
a
ui.notifyevent (two mods echoing is the documented failure).
Atoms, all under plugin: 'glass': turns (last 48: turnId,
durationMs, tools, edits, failed, inTokens, cacheTokens,
outTokens, costUsd, lastToolId, answer, startedAt, finishedAt,
(0.5.0) serverTools), prompts (text, submit and start times, turn
id; last 48), calls (turn id -> main-loop calls with status and wall
time), agents (live subagents), band (open or closed), liveTurn
(the main-loop turn id while one runs, else null; 0.5.1). Module
records (call -> turn, call -> wall time, a turn's call order, unfolded
group calls, foldedTurns, expandAllNow) keep the last 48 turns, as the
atoms do. Each atom is declared in types/index.d.ts; claude plugin validate holds the module to it.
tidepool(default) maps the owner's nvim theme by role: strings olive, numbers and inline code pine, commands iris, builtins foam, paths gold, links mint; its code keys are the engine's reply-fence colors the owner liked.codexcarries One Dark;waterEmpryo's proxysoul-water tokens (rule #5a5870,delBg #33222eafter the live check). (0.15.0)rose(rose-pine) andundertow(sampled off Empryo's desktop frames) are gone: both read muted beside glass's own palettes. A savedroseorundertowfalls back totidepool. (0.10.0)crimsonis Empryo's crimson world ("the heart"), the tokens of empryo.com's own CSS (:root[data-world=crimson][data-theme=dark], 2026-10-16) mapped by role as water's are: pigment#f43a6bfor the verb, the marks and the bars, the violet alt for builtins and raw, the pink nucleus for flags,var,quoteBarandaccentUser, amber for strings; its desktop themeproxysoul-crimsonis not in Empryo's public tokens.ts yet. Derived against the audit ground:rule #664468(the site's#3f2040is 1.34:1),blockBg #301a34(pigment-deep is 1.09:1),faint #7a6679(3.6:1),patha soft sky#8fb6e6, the one cool hue the world lacks,delBg #3a2420an ember apart from the plum tint.- (0.11.0)
tarnishis glass's own palette, not a port: metals aging. Copper (cmd,accent,tool,calloutBar,codeKw) is what happens now; the verdigris it weathers to (sub,num,code,accentUser, the card tint) is what has settled; brass strings, steel paths and links, the violet sheen of tarnished silver for flags, variables andfix; bone text, never pure white. Every value was placed in OKLCH: the nine shell kinds in one lightness band (L 0.74 to 0.83), a quieter twin one chroma step under its role, greys and tints read against#0d1117and night-owl#011627and lifted to the floors (rule 2.5:1, faint 3.5:1, blockBg 1.24:1). Named after a search: "Verdigris" and "Patina" exist as 16-color schemes in the iTerm2 gallery; "tarnish" did not. First in the option list; the default staystidepool. - (0.12.0) Two more of glass's own.
overprint: Risograph inks on black paper, the fluorescent pink, aqua, fluorescent yellow, orange, violet, fluorescent green and scarlet of the ink charts, the tints an ink overprinted on black (violet card, wine removed row, teal added row); loud by design, pink headings approved by the owner.ironbow: the thermal camera gradient with heat for activity, yellow verb and mark, orange builtins, magenta flags, violet strings, cold-blue paths, white-hot headings; done is cold (blue status dots, blue Verified), failure the one red; the mark is orange and the callout bar magenta so the mark stays off every bar. Both drawn throughGLASS_EXTRA_PALETTESfirst (a{ name: Palette }JSONscripts/readme-shots.tsmerges in), approved by screenshot 2026-10-16, then added here. - (0.13.0) Two more, from a brief of "the cool family water belongs
to, not water again".
lagoon: daylight shallows, turquoise lead, sky secondary, hibiscus flags, sun-yellow strings, sand paths, white-sand text, palm green and coral, deep-water tint; the brightest shell band (L 0.76 to 0.88).iris: the flower, indigo-petal lead, leaf-underside teal secondary, honey strings (the beard), orchid flags, sky paths, neutrals with an indigo cast so the card tint and rules go violet-grey. Turned down the same day:noctiluca(a glowing cyan: too close to water),labradorite(blue lead with copper paths: the owner preferred lagoon). Both placed in OKLCH and approved by screenshot. - (0.14.0)
tangerine, from a brief of "crimson's vibrance, water's cyan, tarnish's warmth". A first draft put crimson in the lead seat over water's set; the owner found the red bled on the navy ground. Tangerine, rose pink and violet leads were drawn side by side throughGLASS_EXTRA_PALETTESand tangerine was approved by screenshot 2026-10-16: orange is cyan's complement, so it pops without the bleed. Tangerine takes the verb, tool, keywords and callout bar; water's cyan keeps the accent, band, card tint and builtins; crimson's pink takes the person's row, flags and quote bar; strings and warn are lemon, apart from the lead; failed is a rose red. - (0.14.0)
prism, from the owner's starship prompt (gradient_modernin~/.config/starship.toml), each color in its segment's role: coral (username) the person's row, flags and types; orange (directory) paths; gold (git) strings; emerald teal (toolchains) builtins and raw code; blue the verb, tool and keywords; purple (clock) Claude's mark, the band and the callout bar; the prompt character's bright four for done, failed, warn and H3; white headings. The prompt's two dark greys are left out: bg1 is 1.09:1 on night-owl's ground, and bg3 as a neutral grey card sat heavy on the navy, so the owner took water's tints for the cards and diff rows. Approved by screenshot 2026-10-16. Prism is the first palette to set the optionalpromptBorder: the owner wanted the prompt box in the band's purple while the row stays coral. - (0.15.1) Prism's strings (
strandcodeStr) are Blue Martina#12cbc4from Flat UI's NL palette, not the prompt's gold#e6c229: gold sat at 10.6:1 and a JSON diff, keys and values both strings, glared yellow. Martina is 9.0:1, cool, and a hue prism had no other use for. Merchant Marine#0652dd(2.8:1, sank), Mediterranean Sea (4.5:1, muted) and Android Green (9.7:1, warm, among prism's greens) were drawn beside it; the owner picked Martina 2026-10-17. - (0.15.2) A JSON key is its own highlighter kind, painted in the
palette's optional
jsonKey, else incodeStras before (water's orange keys stay). Prism sets coral#ff6b6bkeys, so a key reads apart from its value, and its strings move from Martina to Mediterranean Sea#1289a7lifted in OKLCH (hue and chroma kept) to#4eb4d3: 7.0:1 on the owner's real ground. Their Ghostty runs atbackground-opacity = 0.96with blur, and the ground measured off a screenshot is#0a1f30, not night-owl's#011627, so every color reads about 10% under its night-owl contrast. No other prism key uses#4eb4d3. Approved by screenshot 2026-10-17. Every palette supplies the fullPalettetype: the prose and shell keys,meta,faint,rowHover,accentUser,text,bold,blockBg,tool, the callout keys, the code keys and the diff tints. A test keepsmarkapart fromok,quoteBarandcalloutBarin every palette. - (0.5.3) Palette fixes from the color audit (contrast against
#0d1117): undertow'sfaint,rule,blockBgandrowHoverlifted to their floors (faint 3.6:1, blockBg 1.3:1, hover a step above the card); codexfaintto 3.7:1 andrulea step undermeta(it outrankedfaint, so table rules beat tree connectors); tidepoolcodeStrisstr(it waserr: a TypeScript string read as an error) andcodeFnthe verb's iris; waterurla sky blue apart from the brand cyan thattool,cmd,accentandcodeKwshare; rosepathrose (it wasbold),flagiris,vargold (it waserr),oka lifted pine (it sat a step fromcodeKw). Water's other shared cyans (sub,num,code,heading3,codeFn) are Empryo's own and stay. - Left out on purpose: an inline-code background (rejected as noise),
a foreground remap, more themes (catppuccin, tokyonight, dracula,
gruvbox, nord: add on request), and a user-set command list (edit
COMMANDSinhooks/shell.ts). - The palette option follows
/configlive: aconfig.sethook onglass.paletteswaps the module's palette after the engine stores the value; rows already on screen keep theirs.
Two directions, settled 2026-10-11.
-
Forward: glass names a theme slot, not a hex, where it mirrors an engine panel: the diff card's tints (
diffAdded,diffRemoved) and marks (success,error) and the hint line's mode word (planMode,autoAccept,warning). Nowhere else: the palettes are tuned sets and a theme key beside them risks contrast clashes. -
Reverse: every palette has a theme.
hooks/theme.tsholds one fixed table fromPaletteroles to engine slots and writes a theme JSON ({ name, base: 'dark', overrides }) for a palette:slot palette role success,error,warningok,err,warnclaudeaccentpermissiontool(the engine's codespans take it too)planModecmdautoAcceptok(0.5.3: the engine's own is a green;warnmade it one color withwarning)promptBorderaccentUser(0.10.1: it wasrule, a grey step in water, codex and rose, so the box looked uncolored beside crimson's plum; the prompt box is the person's, as the user row is; 0.14.0: a palette's optionalpromptBorderwins, prism's is the band purple)bashBordercmd(0.10.1: the verb's color for!mode)secondaryBorderruletextleft to the base: prose is the terminal's own foreground secondaryTextmetainactive,subtlefaintsuggestionurluserMessageBackground,bashMessageBackgroundColorblockBguserMessageBackgroundHoverrowHoverthinkingprivatediffAdded,diffRemovedaddBg,delBgdiffAddedDimmed,diffRemovedDimmedaddBg,delBgdiffAddedWord,diffRemovedWordaddBg,delBglifted 18% toward white (0.5.3: the engine paints these behind a changed word;okanderrthere were bright blocks behind text)Slots with no role (
remember,merged,ide,inverseText, the rainbow and shimmer slots) inherit the base. The two slots the owner tuned by hand in August (userMessageBackground,secondaryText) are set from the palette, by decision: a palette that colors the transcript and leaves the user bubble stock gray reads unfinished. -
/glass-theme(registered atsession.start, as the fold commands are): writes~/.claude/themes/glass-<palette>.jsonfor the current palette with$.fs.write(HOMEfrom$.env.get), then$.config.set({ key: 'theme', value: 'custom:glass-<palette>' }); answers as a transcript line, as the fold commands do (the engine prefixes it withglass:):theme glass-<palette> onor, when the set is denied,wrote ~/.claude/themes/glass-<palette>.json. A plugin cannot select a custom theme, so pick glass-<palette> in /theme.(The/themepicker lists custom themes; confirmed live 2026-10-11.) Refused without a write whenHOMEis unset or relative, or when an entry under that name is a link or not a regular file. It never writes any other file inthemes/: the owner'srose-pine.jsonis theirs. The palette-switch re-sync below reports the same line as a toast. -
Switching the palette in
/configwhile the active theme is a glass theme (themereadscustom:glass-*in$.config.list()) rewrites and re-sets the theme to the new palette, so the two never disagree. Any other active theme is left alone. -
Known limits. Confirmed 2026-10-11 on 2.1.296:
config.setrefuses acustom:value from a plugin (the row takes onlyauto,dark,lightand their daltonized and ansi forms), so/glass-themewrites the file and answers by pointing at/theme, whose picker lists custom themes; glass never edits settings.json itself. The palette-switch re-sync therefore only rewrites the file; selection stays the person's. Still to confirm live: whether$.config.list()reports acustom:glass-*value (the re-sync's trigger), and whether the engine's codespans under acustom:theme takepermission(they fell back to stock in 2.1.241). Body prose and tool-result bodies never take a theme: the terminal fg and stock gray, by the engine.
- 2.1.288 fixed a mod's button running another's action after a redraw,
and 2.1.295 gave Buttons children and a
primaryvariant. The 0.2 and 0.3 Buttons in transcript rows (chevron, Copy) never received a press live. One live retest decides (DEMO.md, step B): a plainButtonin the dots line that toasts on press. Pass: the two Buttons in the user row section ship. Fail: nothing ships and the Buttons bullet there becomes history. The group fold button stays out either way. - No
variant: 'primary'anywhere: a transcript has no main action.
- Floor 2.1.295 in the README. Degradation below it, by construction:
$.ui.notifyis guarded;isSummaryandserverToolUsesare optional props; Buttons uselabelonly; theme keys resolve on any engine that has the slots (they predate the typing). No runtime version check.
hooks/glyphs.ts, by code point: ▎ U+258E, • U+2022, ◦ U+25E6, ☐
U+2610, ☑ U+2611, ─ U+2500, │ U+2502, ├ U+251C, ╭ U+256D, ╮
U+256E, ╰ U+2570, ╯ U+256F, ✓ U+2713, ✗ U+2717, ○ U+25CB, ◉
U+25C9, ◆ U+25C6, ◌ U+25CC, ▸ U+25B8, ▾ U+25BE, … U+2026, ·
U+00B7, × U+00D7, the faces' ● U+25CF and ‿ U+203F, (0.5.0) ⧉
U+29C9 (Copy, only if the Buttons ship). Each gets a cellWidth fixture.
Only glyphs the mod draws live here.
tests/parse.test.ts: the parser's edge cases, streaming callout, tables, fences under list items, the width ruler against Bun's drift.tests/chrome.test.ts: mounted renders of every tree (user row with header, tree rows running and done, a group, an event row, the band with six agents, a running row with its Raster clock and three ticks), the diff card, painted output, glyph widths, palette separation.- (0.5.0)
tests/theme.test.ts: every palette produces a theme whose every value is a 7-character hex, every slot in the table present, the file nameglass-<palette>.json; the forward keys are the literal slot names.tests/chrome.test.tsgains: the summary trailer row; a server tool row from a turn record, with and withoutendedAt; theTurnDurationrowdisplay: nonewith no server tools; the dots-line Buttons validate mounted (only if they ship). Hook behaviour (the notify cases) is checked throughmock.clockand the kit's engine where the kit reaches it; what it cannot reach is a DEMO.md step. tests/demo.test.tswritesdemo/<before|after>/*.txttext dumps of the fixtures DEMO.md names; thebefore/set is captured on 0.4.25 before any 0.5.0 change and committed once.- Live, after every release, the DEMO.md checklist in Ghostty.
- Always: run
scripts/check.shbefore a commit; keep both manifests at one version; build glyphs from code points; read no atom in a tree row hook; callnext(e)on the band; keep/foldand the other commands working without any Button. - Ask first: adding a userConfig option; any new notification case; a new
palette; writing anywhere outside
~/.claude/themes/glass-*.json; changing a rule this file marks as the owner's request or pick. - Never: write or edit the owner's own theme file; notify from a render
hook, a timer or a
ui.notifyhook; add per-prompt work that slows the prompt; pass a transcript row back to the engine to get a feature (the styling goes with it); draw withdimColorin a reply; usetruncate*on reply text.
- HISTORY folding of old turns: the engine owns transcript folding.
- Side call rows,
Effort: auto → xhighrows, per-agent cost: Empryo internals with no event or field in this API. - Icons per tool kind: the ASCII rule and font variance; the tool name in
toolcolor is the icon. ReviewandTill passfooter actions: product features, not drawing.- Past turns dimmed and lifted on hover: the API allows it
(
Text.hover.dimColorwith ascopeper message), but it costs one state read per text block, anddimColorover colored spans is unverified. Try after 0.5.0 ships; drop if it flickers. - Tool-result bodies in a theme color: the engine draws them stock gray wherever glass does not draw the body.
- Pictures inline (MCP screenshots, image Reads): built in 0.6.0 to 0.8.0, removed in 0.9.0 by the owner's choice, 2026-10-12. A picture scrolled back up came back as an empty gap, and a long session that drew pictures lost glass entirely (cause not logged). The engine's text row stays.
Carried from earlier passes, each a DEMO.md step: the skill-loaded row on
the trunk (0.4.25); the band four cells short of the [-] mark; the
narrow-band column order; a blank fence line's row; the hint string
carrying -- INSERT --. For 0.5.0: the Button press; config.set of a
custom: theme; codespans under a glass theme; the notify channel's
isSent; the tall-reply duplicate rows (engine fixes in 2.1.289 and
2.1.290 may have closed it: reproduce before filing); a tabbed fence after
the engine's own tab-stop change in 2.1.295. For 0.10.0: the view
press on a band row, back on the row in view, Esc from the agent view;
a deny toast when the agent has finished; the dots line's counts under
a turn that spawns subagents (2.1.299 raises turn.start for them; 0.9.1
guards it); crimson's blockBg and rule on the owner's ground.