aboutsummaryrefslogtreecommitdiffstats
path: root/include/app_internal.h (follow)
Commit message (Collapse)AuthorAge
* tab bar: a sidebar that fits the terminal, and gives up the edge lastuser2026-10-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | `tab_bar_width` was a reservation: the configured columns came off the screen whatever the screen was, and the only fallback was all the way back to the top row. On a narrow terminal that is chrome displacing the thing it is chrome for, and the jump to the top moves every target you were aiming at. It is a ceiling now. app_tab_bar_cols takes the app, because the answer depends on the terminal and not only on the config: held to the columns left after a pane's collapse width, and to a third of the screen, and narrowed rather than dropped. TAB_BAR_FIT (14) is the narrowest width this code will invent on its own -- a config that asks for less is taken at its word -- and 0 means there is no room for a sidebar at all, which is the one case that still falls back on top. Height counts too, which it did not before: a sidebar spends a row per tab, so a wide but short terminal runs out of list before it runs out of screen, and a tab with no row is not clipped, it is unreachable. tab_bar_min_rows is that floor. Not when the top strip could not seat them either -- moving them would gain nothing, so the side you configured stands. All of it is re-derived per frame, like the layout, so a resize moves the bar and nothing remembers that it did. test_tab_bar also stops depending on where the suite is run from. An unnamed tab borrows the basename of its focused pane's cwd, so eight checks read `tests` under `make test` (which cds there) and `slosh` from the repository root, where they failed. They are about padding, alignment and the index, not about that derivation, so they name the tab first -- by id, since `set-name` takes 0 for the focused *pane* only. 147 checks, green from either directory.
* panes: the focus a closed tab left behinduser2026-10-09
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A tab's focus outlived the tree it pointed into. close_leaf's last-pane branch freed the leaf and removed the tab but never cleared `t->focus`, and tab_remove only shifts the slots *after* the one it removes -- so with the last tab gone, `a->tabs[0]` kept the stale tab_t while `ntabs` was 0 and `cur` was 0. `cur(a)` is `&a->tabs[a->cur]` with no bounds check, and `a->quit` is a request rather than a barrier: the loop keeps serving commands, and the next one to ask for the focused pane was handed freed memory. From outside it looked like a random crash, because what the freed chunk held depended on what had been allocated since. Two from one afternoon: a pane_t recycled into a `panes` JSON reply, whose `term` pointer was then the ASCII of `k":true,` and faulted as a general protection fault; and the same node read back zeroed, which left pane_clear_bell testing a flag at NULL+0x5d. Typing `exit` in the last pane is the whole recipe. close_leaf drops the focus with the tree tab_remove blanks the slot the shift vacated app_compose tests ->pane as well as ->focus, the way the search beside it already did app_apply_layout clears restore_focus up front -- it is a node pointer, and an apply that fails before consuming it leaves it naming a tree the next apply frees Two more the same reading turned up, both in close_leaf: an emptied split set the focus to the node just freed, and a leaf whose parent disagreed with that parent's child list underflowed `p->nkids - at - 1` into a memmove of nearly SIZE_MAX. FOCUS_CHECK -- behind -DSLOSH_FOCUS_CHECK, compiled out otherwise -- is the invariant all of this turns on: every tab's focus is a leaf of that tab's own tree. It is what found this one. Committed with --no-verify: the files are deliberately partly staged, and the staged blobs were checked against clang-format directly.
* layouts, moves: build panes without pulling the view to themuser2026-09-29
| | | | | | | | | | | | | | | | | | | | | Applying a layout landed on the first tab it made, and moving a pane into a tab took focus there. For a program building a pane in the background that is two failures at once: it yanks the view off the human mid-sentence, and it leaves the *next* caller to read focus as "where I am" and put its work in a stranger's tab. `apply-layout focus:false` builds the tabs and keeps looking at whatever was on screen; a layout naming an `active` tab still wins, because that is a statement about where to be. `move-pane` gains `beside` (which pane in the destination to split, rather than whatever it has focused) and `focus:false` -- the two things a caller placing a pane next to *itself* needs. That uncovered a sizing bug that was unreachable before: a pane's size comes from a layout pass, and a tab nobody has looked at never had one, so a pane built in the background stayed 1x1 and its program drew into a single cell. The jump is what used to size it. layout_tab() gives a background tab a real pass, borrowing the tab cursor for its length -- nothing composes, reads input or draws a frame in between.
* theme: the busy spinner is its own colour (tab_status_spinner)user2026-09-22
| | | | | | | | | | | | | It was drawn in tab_status_busy, the colour of the words it sits beside, which made one decision out of two. The words have to stay readable at a glance and a mark does not: a theme may well want the glyph loud and the text calm -- or the text in the accent and the mark a quiet dot -- and with one entry neither is available without moving both. Unset it follows tab_status_busy, derived after it and from whatever that resolved to, named or derived, so nothing changes for anyone who does not ask and no theme in contrib needs a line. `--dump-theme` writes both, like every other colour the compositor draws.
* osc 5577: `busy`, and a sidebar that shows it (busy_mark)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A status is a line of text, and a line of text cannot say whether it is still true. "building 3/7" reads exactly the same while it builds and ten minutes after it stopped, which is the one thing somebody reading it from another tab wants to know -- and the only workaround available to a program was to write the answer into the words and keep rewriting them. So `busy`: printf '\033]5577;1;status;make test\033\\' printf '\033]5577;1;busy;1\033\\' ... printf '\033]5577;1;busy;0\033\\' A flag of its own rather than a field of `status`, because a status is the *whole* payload after the verb -- `status;a;b;c` has always been the text "a;b;c" -- so anything added to that line would change what every existing sender means, including the five pi extensions D1 promises byte compatibility to. Orthogonal, so a program can leave one line up and toggle the other; anything but 0/false/no/off is on; `clear` takes both; and a pane whose program has gone is never busy, whatever it last claimed, because a spinner turning under a dead tab is a lie you keep looking at. In a sidebar the row turns `tab_status_busy` -- the theme's accent, derived like the other status colours, so no theme needs a line -- and takes a spinner in the column `tab_bar_padding` was already holding blank, so nothing reflows when a program starts or stops working. `busy_mark` is the frames, separated by spaces: one frame is a static mark and costs nothing, several make it turn, and only then does the session keep a frame clock -- via the same `animating` flag an animated shader uses, so the cost lands only while such a row is actually composed, and `""` drops the mark entirely and leaves the colour to say it. The frame is a pure function of the clock, like every other pulse here, so two clients attached to one session show the same one. `panes` now reports `status` and `busy`, which is the scriptable half of the same fact: what a pane says about itself, without reading its screen.
* themes: a picker that wears them as you browse (C-a t)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | `theme_name` is a line in a file, and a name in a list tells you nothing about a colour scheme. So the picker previews by *applying*: moving the selection puts that theme on, which is the only honest preview of a palette meant to dress a whole session -- a swatch in a list would be a small lie about it, and the session is already on screen behind the box. It opens on the theme being worn rather than at the top, so the first arrow press moves off it instead of silently changing the session, and the row your config names says so, because "which one is this" and "which one comes back on restart" are two different facts the moment you switch. Escape puts back what was worn, since a picker that changed your session by being looked at is one you stop opening. Enter keeps it for the session and says as much. Only C-s writes `theme_name` into your config, which is the one place in the UI where a keystroke edits it, so it is a deliberate chord and the footer says so: "C-s keeps it, esc undoes". Reuses the picker the finder, the palette and the projects list already wear -- one machine with a subject, so the query, the selection, the mouse, the frame and the scroll behaviour are the ones already tested. The list is scanned on the keystroke, like the projects are: the moment it opens is the moment it has to be right, and a theme written since the session started is exactly the one being looked for. With nothing to list it toasts the directory it looked in rather than opening an empty box. `t` was free: tabs cycle on tab, and new-tab has had `c` since the first day.
* tabs: a plate under the ones you are not in (tab_idle_bg)user2026-09-22
| | | | | | | | | | | | | | | | | | | | | | | | | | | The active tab has been filled with the accent since the strip existed; every other tab was words on the background. That leaves the strip with one shape on it and nothing to compare it against -- where a tab ends, how far the row you are about to click reaches, and in the sidebar which rows are tabs rather than statuses, all had to be read off the text. tab_idle_bg fills them. Barely off the background and in the label's own grey, because this is not a second highlight: it gives a tab edges, and it gives the accent something to be brighter than, which on a strip of short labels is most of how the eye finds the tab it is in. Hover keeps it for the same reason the active tab keeps its fill -- losing the plate under the pointer would read as the tab going away -- so hovering still lands on the text, and the bell mark sits on the plate rather than punching a hole in it. Derived a third of the way from default_bg to tab_idle when nobody names it, the same bargain tab_status_fg and tab_status_stripe already make: both ends are colours every theme in contrib defines, so each gets a plate in its own family -- paper lightens, phosphor greens, mono stays grey -- without a line added to any of them. Naming it wins, and naming your own background is how you ask for the bare tabs back. The `+ new tab` button and the gaps between tabs stay unpainted: the button is the one row in the list that does not mean "go here", and tab_gap is air between plates, which is what makes them read as separate tabs at all.
* sidebar: a status can have more than one row (tab_bar_status_lines)Peter Geissler2026-09-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Sixteen cells is not enough for a sentence, and a sentence is what a pane has to say: "add a summary…" is most of a status and none of its meaning. tab_bar_status_lines 3 word-wraps instead of cutting, and the same column carries forty-eight cells rather than sixteen. Wrapped, the rows under a tab stop being a list of panes and become a paragraph each, so the shape no longer says where one pane's status ends and the next begins -- both are italic text down one column. Alternate panes are drawn on alternate bands to say it instead: tab_status_stripe, derived from the theme like tab_status_fg, so a theme that has never heard of any of this still gets a band in its own family. Nothing is painted at one line, where there is no ambiguity to resolve and a band would take a translucent terminal's background away for nothing. 1 is the default and is the old behaviour exactly, down to cutting at the column rather than the last word before it: with no next row to move the word to, breaking early would spend cells on nothing. The reason to prefer a word boundary only exists once there is somewhere for the word to go. Rows count against tab_bar_status as before, so wrapping trades panes for words within the same allowance, and the allowance itself still gives way to the tabs. A status that will not fit whole is dropped whole and reported with the ellipsis, rather than shown as a fragment that reads like the whole thing. Every row of a status is the same `find:` door. tabstatus's buffer goes to 256 to match pane_status's own slot: at one line the drawing cut it anyway, but wrapped it is the whole string that has to survive the trip.
* sidebar: statuses wear italic instead of an indent, and can wear colouruser2026-09-10
| | | | | | | | | | | | | | | | | | | | | | | Three columns of indent is a fifth of a sixteen-column sidebar, spent saying 'this belongs to the row above'. The slant says it in none: a status now starts in the same column its tab's name starts in, and is set in italic. That also survives the monochrome themes in contrib, where a colour-only distinction would have had nothing to spend. tab_status_fg / tab_status_bg are there for themes that do have colour to spend. The fg follows tab_count when a theme does not name it -- the same declared/derived split apply_scrolled makes -- so the six themes in contrib get coherent status rows without a line added to any of them. The bg is unset by default, because a band is opt-in and 'none' is what a translucent terminal wants; the row is padded so a band that *is* set covers the row rather than the words on it. Two things fell out. The padding loop existed three times and is now pad_cells. And cb_color wrote #000000 for an unset colour, so quietly pinned attach_bg -- 'gray ink on the terminal's own background', says its comment -- to a black band. Unset colours are now written commented out, the same trick the states block uses for what it wants re-derived rather than pinned.
* tabs: the strip can stand on its side (tab_bar_side left/right)user2026-09-10
| | | | | | | | | | | | | | | | A sidebar lists one tab per row down the chosen edge, tab_bar_width columns wide; the panes trade the strip's row for the sidebar's columns in tab_area(), the one place that arithmetic lives. The per-tab cell drawing is extracted from draw_tab_strip (label, rename editor, bell, hover, drop states, hit) and shared, so every mouse verb -- click, double-click rename, drag to a tab, drop on + -- works in both shapes because the hits move with the cells. The strip's indicators (pane count, prefix badge) take the sidebar's bottom rows, reserved first under the same budgeting rule the strip applies from the right. The status line below keeps the full width. A terminal too narrow to give up the columns falls back to top, re-derived per frame, so a resize moves the bar without anyone storing state.
* search: a scrollback search bar on /, matches highlighteduser2026-09-08
| | | | | | | | | | | | | | | | | | | | | | | | C-a / opens an inline search bar on the focused pane's status row, on lib-vt's GhosttySearch (D4's pattern: the scrollback walk, reflow and alt-screen survival are the library's, the UI and the wiring are ours). Type to search live; the viewport follows the current match; up/down (or C-n/C-p) step through them with wraparound; Enter keeps your place, Escape snaps back. A k-of-n counter reads on the right, or "no matches". Every match on screen is tinted, and the one the bar is on is tinted apart -- warm gold by default, painted after the shader pass so the scrolled wash does not dilute a match you are hunting for. Four themeable colours (search_fg/bg, search_cur_fg/bg). Bare / took the key from help, which keeps ?; that needed the legacy decoder to read the shift out of a punctuation byte the way it already did for capitals -- without it ? and / decode identically (SLASH, no mods) and cannot be two keys on a client without the kitty protocol. tests/test_search.py drives it end to end (39 checks): the bar, the counter, stepping and wrap, all-match vs current highlighting, Enter/ Escape, new output updating the count, case-insensitivity, the keyboard belonging to the bar, click-away, and that / and ? no longer collide.
* app: cut the modals, toasts and splash out of the images under themIngmar Steen2026-09-06
| | | | | | | | | | | | | | | | | | | | | | | | Overlays are painted over the panes and the compositor needs no more than that: painted later is on top. A placement is not painted, though -- the client's terminal draws the image over the cells, in whatever order they arrived -- so an overlay that is only drawn is an overlay an image sits on top of. The cheatsheet came up under a picture and was unreadable through it, in the terminal and in the mirror both. So a frame now records where its overlays landed, and the graphics pass takes them out of a placement exactly as it takes out a float: the modal's box (the cheatsheet and the picker wear the same frame), each toast, and the splash. The list is rebuilt every compose, so a rect that is not registered again is one that is no longer on screen. Only the opaque things are registered. The scrim behind a modal tints what is behind it rather than hiding it, and an image cannot be tinted by us at all -- so a picture beside the cheatsheet stays bright while the text around it is pushed back, which is wrong-looking but a great deal better than the image disappearing while a modal is up. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CRz3zwZJvBvzsR9k4cj6dd
* layout: a float sized but not positioned is centred, per axisuser2026-08-30
| | | | | | | | | | | | | | | | docs/layouts.md always said an absent coordinate takes the centred default; the loader said 0, and a layout float with only w= h= landed in the top-left corner. Now an axis the file does not name is FLOAT_CENTRE, a sentinel no real coordinate can reach, and float_clamp answers it with the middle -- every frame, at whatever size the terminal is by then, until an explicit move or resize writes a real number over it. The webdemo's welcome float is the customer: it must sit centred on a terminal whose size the layout file cannot know. The dump writes a still-centred axis by omission, because the number it happened to land on would pin to this screen's size the one intent that deliberately has none. Every mutating verb already writes back from the drawn rect, so the sentinel survives nothing it should not.
* selection: drag past an edge scrolls the viewport under ituser2026-08-27
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Text taller than the viewport was not selectable in one gesture: the drag stopped dead at the pane's border. Two faults, one visible and one waiting. The visible one: DRAG_SELECT motion subtracted the content origin in uint16_t, so a pointer above or left of the pane underflowed, the grid lookup failed, and the selection froze. The waiting one: the anchor was a pair of *viewport* coordinates re-resolved on every extend, so the moment anything scrolled mid-drag the anchor would have drifted with the viewport, re-anchoring to whatever slid under the press. The anchor is now a tracked grid ref -- the thing lib-vt documents as being for 'long-lived anchors such as selections' -- so it follows its cell across scrolling, pruning and reflow, and only the near end of the selection moves. If a program flooding output prunes the anchored row out of scrollback entirely, the selection keeps what it had; there is no cell left to anchor a new answer to. Motion clamps into the content rect (a pointer past an edge is pointing at the nearest cell), and holding past the top or bottom edge scrolls on a clock rather than on motion, because a resting pointer sends no events and 'keep going' is exactly what resting it there means. app_tick() does the step, app_next_deadline_ms() wakes the poll loop for it -- the same mechanism toasts and hover guides already use -- and both front ends call the tick, so the behaviour exists under the server and the headless harness alike. Rows per step is how far past the edge the pointer sits: one row past creeps, further hurries, so the hand already dragging is also the throttle. The metronome is select_scroll_ms, a knob so the test can configure the clock down instead of sleeping. The test drags to one row above the pane and simply holds: the viewport walks to the top of scrollback (clamping makes the endpoint exact), the copy starts at a line that was never on screen with the press, and still ends at the pressed row -- that last check is the tracked anchor's.
* compact: shared borders instead of gaps (D23)user2026-08-25
| | | | | | | | | | | | | | | | | | | | | | One knob, `compact true`: panes pack flush against 1-cell divider lines that meet in real junctions, one frame rings the tab, and a pane's title rides the line above it -- a boundary costs one cell where the default look spends four. The lines are strokes: each write says which edges of its cell it needs, unions with what is there, and never clobbers text, so ├ ┬ ┼ fall out of paint order and the focus ring recolours without inventing arms. The divider *is* the gap (eff_gap, one answer for the layout, split_fits and the resize clamp), so gap hits, hover hints and corner drags work unchanged. Interior edges give up click-to-split -- one cell cannot hold two verbs -- and a pane's status row overlays its last content row. Floats, zoom and the D6 flatten keep classic frames through place_hidden: zero new tree cases. Underneath it, mode or no mode: a border drag now moves the boundary on that side, converted on the first motion so the press that never moves keeps its old meaning -- the handle's click still splits, the rim's is still deliberately nothing (drag.rim). Which is also the fix for gap 0, where flush panes leave no gap cell to grab and the resize gesture had silently vanished with it.
* slosh: floating panes and the drop grammaruser2026-08-20
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | A pane can float: lifted out of the layout, drawn over the tiled panes and under the modals, freely moved and shaped. The model is a flag and a rect on the leaf, exactly like `minimized` -- the seat in the tree is kept, layout skips it, the siblings absorb its share, and un-floating returns it home. The drawn rect is the wanted one clamped to the tab every frame, never written back, so a terminal squeeze round-trips exactly; an explicit move or resize *does* write back, because the hand is the author of the intent. Floating pops the pane to the centre: the jump is what says it happened. Painted after everything tiled, so the hit list -- searched backwards -- resolves an overlap to the float you can see: the z-order and the click order are the same list. It casts a shadow (float_shadow, one number), and `floating` joins the states table above `unfocused`, so a float is never dimmed as merely-unfocused. The hands: the title moves it, any other border resizes it (a bottom corner on both axes; the grabbed edge pins at walls and floors, never sliding the window), H J K L move a focused float, the =/+ key grows it about its centre, `-` shrinks it (its split was a refusal anyway), and hovering shows which edges a grab would take, from the same derivation the press uses. Equalize moved to `0` to free the key; `C-a f` floats (finder went to `s`, where it reads as search); `C-a F` opens a new floating shell -- the throwaway terminal; the function row f1..f12 became nameable in configs along the way. Guards throughout: the last tiled pane cannot float or minimize away, closing it lands the top float, splits are refused with their own words, floats are not swap targets. Dropping got a grammar: while a pane is carried, the pane under the pointer subdivides into a swap centre and four edge bands meaning "insert me beside this pane, on this side" -- detach_leaf + place_beside, the surgery cross-tab moves already had. Bands are thirds (the first map of quarters showed the swap owning half of each axis), the hovered band fills the half the drop would hand over, the release resolves against the painted frame's hits so it cannot disagree with the fill, and a drop between siblings along the axis is a reorder that always fits -- refusing it was refusing the most ordinary drag there is. Also: rename-pane/rename-tab as palette actions (renaming was a double-click and nothing else, the one verb the palette could not reach), and C-u clears the rename editor like it clears the picker's query. Design log in PLAN-FLOATING-PANES.md; to fold into DESIGN.md as D22/M10-11.
* slosh: the logo flies into place -- a particle engine, statelesslyuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | | | | | | | | Every non-space glyph of the logo is a particle that flies to its cell over the first two fifths of splash_ms, then *is* the logo for the rest. There is deliberately no particle state anywhere: a position is a pure function of (target, elapsed, seed), recomputed every frame like the layout and the pane states and for the same reason -- physics that is stored can drift, physics that is derived cannot. The seed is the splash timestamp, so every attach flies differently and one splash is coherent frame to frame; a per-particle hash staggers departures, and the delay rescaling lands every straggler exactly at the end, because an almost-assembled logo reads as a bug. Five motions, picked by the clock and pinned by {"cmd":"splash", "motion":N}: rain (fall in, column by column), scatter (converge from everywhere), slide (rows enter from alternating sides), vortex (spiral in around the centre, orbit shrinking and unwinding), shuffle (the glyphs trade places, then sort themselves). The colour chains keep playing over the box throughout, so particles catch their hue as they cross into it. expr_sin() is the expression language's own sine exported for C-side animation: a motion sketched in the repl and one compiled into the binary cannot disagree about what sin(90) is. The tests learned two things the flights taught: the logo's opening run appears three times per line and assembly is staggered, so a middle occurrence can land first -- "assembled" is the row whose first block glyph is where the match begins -- and windows need margins that survive a parallel suite on a busy box.
* slosh: the splash learns colouruser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | One tint is one colour, and every effect wore the theme accent -- six effects that were all the same dull blue. An effect is a *chain* now: phase-shifted tints in loud literal colours, mixing per cell, which is how a fixed-colour pass machinery does rainbows. Passes may light the backdrop's background too, so the box is a scene rather than tinted text, and the logo is drawn white -- the canvas that takes every hue at full saturation, where tinting blue towards pink lands on mud. The six: spectrum (rainbow bands), plasma (two interference patterns in clashing hues), fire (heat up the glyphs, embers in the box), rings (rainbow ripples), glitch (a reveal under two colours of static), aurora (green and violet curtains over cyan). The splash is deliberately the one place that does not defer to the theme: it is branding wearing a demo, not chrome. {"cmd":"splash","fx":N} picks an effect by index -- a test needs a deterministic one, and "show me the aurora again" deserved an answer. The new test counts dominant-channel hue families on the logo rows, because two hundred shades of the same blue is exactly the failure it guards against.
* slosh: greet an attach with the logo, wearing a random shaderuser2026-08-19
| | | | | | | | | | | | | | | | | | | | | | | | logo.txt is the source of truth; the Makefile bakes it into build/logo.h the way version.h is baked. When a client attaches (MSG_HELLO turning a connection into the display), the logo appears centered over everything for splash_ms (900, 0 for never), on a cleared backdrop, in the accent -- and one of six effects runs over its rect through the ordinary shader pass: a diagonal sheen, plasma, rings, sparkle, a left-to-right reveal, a breathe. Which one is picked by the splash's own timestamp, so every attach looks a little different without anybody keeping a counter. Every effect reads the clock, so the frame clock runs only while the splash is up (note_animation, same as any animated chain), and `since` in a splash context is milliseconds since it appeared, so one-shot effects sweep exactly once. Any key or click ends it and still lands where it was aimed: a greeting is not a modal. A screen the box does not fit on gets no greeting rather than a cropped one. Headless and scripted sessions never attach, so the whole fast suite never sees it; {"cmd":"splash"} triggers it on demand, which is what makes it testable from a scripted session -- and lets you replay the greeting whenever you like.
* slosh: split app.c into five units, mechanicallyuser2026-08-19
app.c was 7k lines wearing section markers; now the markers are files, cut exactly along them, no behaviour change: app.c the session core: config in force, pane and tab lifecycle, walking, input routing, introspection app_layout.c layout, tree edits, resizing, equalising, rotating, focus app_session.c layout files, checking, workspaces, dumping a session app_draw.c drawing, the shader passes, kitty graphics app_ui.c the finder, modals, the palette, the project picker, renaming include/app_internal.h carries what the five share: the tree/tab/app structs, CFG and the palette macros, and the 50-odd formerly static functions the cut made cross-unit, declared where they are defined. They keep their names; only the five units see the header. One test moved with the code: test_portable_layouts' staleness check greps the source for the layouts section, which now lives in app_session.c. Mechanical, so blame for anything in the four new files continues in app.c before this commit.