Tauri's terminal context menu leads every row with a Material glyph —
copy, search, search-online, AI, translate, paste, paste-selected,
clear, clear-all, select-all. Ours had none, which is most of why the
two menus do not read alike.
Every row now carries one, including the submenu triggers and the rows
inside them, and a row without an icon still reserves the column so the
labels line up. Six glyphs were missing from the bundle and are vendored
through the manifest like the rest.
Sending a prompt to the AI panel from this menu — or from anywhere else
— now writes it into the composer as well as the draft. The composer
owns its own buffer since it became a real input, so the two would
otherwise show different things.
The new-file, symlink, move and inline-rename boxes were the last
hand-rolled inputs in the app — each a label div over a draft string
with its own key handler, and the symlink one with a focused-field enum
to decide which of its two boxes a keystroke belonged to.
They are real inputs now. The handlers keep only the keys the dialog
owns, and the remote-name and link-target length limits moved with the
text into the one place that writes it.
Not verified in the running app: the file manager needs an SSH session
and no server is reachable from this machine.
The last of them: the quick command variable prompt and category rename,
the AI ask panel's composer and model search, and the process manager's
nice value. `transfer_input` is deleted, and with it the focus handle
each fake input carried — `NyaTermApp` and the feature states lost
fourteen of those, along with nine key handlers and the focus-steering
they needed.
The AI composer takes newlines now, the way Tauri's does, with Enter to
send and Shift+Enter for a line break.
One more GPUI trap, recorded with the others: a box is content-sized,
and an empty field's content is nothing, so it renders as a ~30px square
that wraps its placeholder a character or two per line. A flex column
stretches its children, so most callers were fine; a `flex_none` control
slot and a plain block parent were not.
Word separators, the X11 display, the master password, the recording
and download paths, the default editor command, and the three boxes on
each AI credential card. Same shape throughout: a real box, the key
handler that stood in for typing gone, and only the keys the form itself
owns left behind.
The credential rows had to stop iterating the credential list in place —
each row builds three inputs, and creating one needs the app mutably.
Four helpers between them covered most of what was left: the AI settings
field, the translation field, the cloud sync field and the three filter
boxes (active sessions, Docker, processes). Each hosted a label div over
a draft string with its own key handler; each is a real input now, and
the handlers are gone.
The field enums carry their own input id, so an edit finds its way back
to the right draft without an editor tracking a focused field, and the
reverse lookup scans the variant list rather than a second match that
could drift from the first.
Secrets stopped being displayed. `cloud_secret_display` rendered a row
of asterisks sized to the draft, or the word "set", into the place the
text goes; the boxes mask themselves now and hold the draft, and whether
a secret is stored is already badged beside each provider.
SSH keys, saved passwords, OTP entries and credential rules all shared
one label-div field helper and one hand-written key handler each. The
helper now hosts a real input, the handlers keep only the keys that
close or save the editor, and each editor's boxes are dropped when it
opens or closes so the next one seeds from its own values.
Secrets stopped pretending. A stored password used to render as a row of
bullets sized to the draft, or as the literal words "unchanged" sitting
where the text goes; the box masks itself now and says "unchanged" in
its placeholder, and the reveal toggle unmasks the box rather than
swapping the string. The numeric OTP fields keep digits only.
An empty field also stopped seeding itself with a space — the old
helper needed one to keep its row height, and it showed up as a single
bullet in every empty secret box.
Tauri's is a textarea: Enter is a newline and Ctrl/Cmd+Enter sends. Ours
sent on Enter and had no way to type a second line, which the "Line by
line" send mode exists for. It now wraps, takes newlines, and sends on
Ctrl/Cmd+Enter; Escape still clears.
Hex is normalised as it is typed — digits regrouped into pairs, anything
else dropped — and the box is written back with what the draft holds, so
the two cannot drift apart.
Where the real inputs have landed, how many label-div "inputs" are left,
and the three GPUI behaviours that make converting one look like it
worked when it did not.
Label, description and the script box are real inputs now. The script
box takes Enter as a newline, so the dialog's Enter is unambiguously a
save, and paste goes through the box rather than a hand-written
clipboard branch.
Two things this turned up:
- The editor surface grabbed focus back on every click, exactly as the
connection editor used to, so a click could never land on a field.
- A wrapped box has to stretch the row that holds it. The field asks for
its parent's height, and against an indefinite one the percentage
resolves to zero — the box rendered as an empty rectangle that could
not even be clicked.
The first of the file manager's dialogs onto the shared input. The name
box takes a caret, a selection, IME and the clipboard; the dialog keeps
Escape and Enter, and the 255-character remote-name limit moved with the
text into the one place that writes it.
Not verified in the running app: the file manager needs an SSH session
and no server is reachable from this machine.
The last of the network panel's three dialogs. Same shape as the other
two: a real box, no hand-rolled key handler, Escape and Enter owned by
the dialog.
The same treatment as the tunnel dialog: the boxes take a caret, a
selection, IME and the clipboard, the key handler that stood in for that
is gone, and Escape and Enter belong to the dialog.
The password box masks itself, so the "keep the stored one" hint moved
from the value to the placeholder — it used to render a row of asterisks
sized to the draft, in the place the text would have gone.
The tunnel dialog's boxes are real inputs now, so they take a caret, a
selection, IME and the clipboard, and the hand-rolled key handler that
stood in for all of that is gone. Escape and Enter moved up to the
dialog, which is where they belonged: the boxes deliberately leave both
unconsumed.
The dialog also has somewhere to be. It was drawn inside the network
panel, and an overlay fills its nearest positioned ancestor — a panel a
couple of hundred pixels wide — so a 640px form was squeezed into it
until every caption wrapped onto three lines ("Tunn el Type"). The
network dialogs are now hosted by the root, the way the transfer
overlays already were.
Outside the connection editor every "input" in the app is a label div
over a draft string, with a focus handle per box and a hand-written key
handler — no caret, no selection, no IME, no clipboard, and a little of
it reimplemented in each panel. The search-engine editor showed the
failure plainly: the placeholder and the value were stacked inside one
box that only had room for one of them.
The connection editor solved this by owning a TextField entity per
field. Threading that map through every panel's state would be a lot of
churn, so the fields live in one registry keyed by a string id, created
the first time a panel renders one. A panel keeps no state beyond the
value it already had, and edits arrive as one event with the id
attached, which the registry routes by prefix.
The search-engine editor is the first caller: its two boxes are real
inputs now, and the key handler and focus-steering they needed are
gone. Structural changes forget the ids they invalidate — adding an
engine shifts every row's index.
The title had `overflow_hidden` but nothing stopping it from wrapping,
so it laid itself out as a column of a few characters each and the tab —
one row tall — showed whichever line happened to land there. A local
PowerShell session read "ste", out of the middle of "System32".
A shaped terminal row is laid out with `force_width`, which puts glyph
*n* at `n * cell_width` no matter how wide the glyph is. A CJK character
covers two terminal columns, so every character after one was pulled a
cell to the left and the row piled up on itself — a line of Chinese was
unreadable, and so was the PowerShell banner.
Each double-width character is now followed by a space, so one glyph is
one cell again. The space advances the glyph index without painting, and
a cell's background is drawn as a rect underneath either way. Both row
paths needed it: the styled one and the plain-text fast path.
Captions move above their boxes, so a field's whole width is what was
typed into it rather than half a line under a label. Host, port,
username and the serial port carry the red required marker, the fields
that had one in Tauri show their placeholder again, and the host row
gives the host the room while pinning the port at a fixed width instead
of splitting the row down the middle.
The port and the other numeric fields gain the spinner Tauri has, with
the range each one is actually valid over. Typing still works — the
buttons are a shortcut, not the only way in.
Switching the connection kind rewrites the default port on the draft;
the box now hears about it, so Telnet opens on 23 rather than showing
22 while the draft says otherwise.
An open select now takes focus, so Up/Down walk the options, Home/End
jump to the ends, Enter takes the highlighted one and Escape closes
without changing anything. The highlight is shared with the pointer, so
moving the mouse does not leave a second, stale one behind, and it
scrolls itself into view in a long list.
The keys are answered by the popover rather than the editor surface,
because only the popover knows the option order — the choices are built
where they are rendered, and mirroring them into the runtime would be a
second source of truth. That is also why the field map handed to the
sections became a struct: reading the app entity again from inside its
own render panics.
Two things this uncovered:
- Every select rendered its current value into a box that collapsed to
zero width, so all of them looked empty. Giving the value a flex basis
brings back "None", "COM1", "8" and the rest.
- Enter in the group popover's "new folder" box now creates the folder
again, and the box is emptied afterwards — it holds its own buffer, so
clearing the draft's copy left the typed name on screen.
The description box was the last field still faked with a label div,
because the widget could only lay out a single shaped line. It now
shapes through `shape_text`, so a field opted into `multi_line` wraps at
its own width, grows downward, scrolls vertically instead of sideways
and takes Enter as a newline rather than handing it to the dialog.
Up/Down move by hard line, keeping the column.
Space arrives without a `key_char` on some platforms — the terminal's
key encoder already names it for the same reason — so it is named here
too, or typing a sentence produced one long word.
Editing any field now clears a stale validation error, and the box is
`flex_none` so the form cannot shrink it into a half-drawn row.
The field treated any key carrying a `key_char` as text. Tab's is "\t" and
Enter's is "\n", so pressing either in a single-line field inserted an invisible
control character instead of leaving the key for the dialog — a Tab typed into
the connection name looked like nothing had happened at all.
Navigation and dialog keys are now named explicitly rather than inferred from
whether the platform produced a character for them.
The description box is left off the editor's field list on purpose: it is the
one multi-line input in the dialog, and this widget is single-line, so it keeps
the legacy key routing until multi-line lands.
Both editor selects rendered their option list as an ordinary child of the
trigger, so opening one pushed everything below it down the dialog — the group
select shifted the icon, name and host rows every time it was touched — and the
list was clipped by whichever ancestor hid its overflow.
They now open through `deferred(anchored(..))`, the same treatment the context
menus got: laid out against the window, offset below the trigger, snapped inside
the window edges, and painted above the dialog instead of inside its flow.
Clicking outside dismisses only the select that is open, so a stale click for a
different one is ignored.
The options themselves read better: a wider panel that fits a group path or a
proxy summary without wrapping, taller before scrolling, and a tick on the
current value rather than tint alone. The "new group" input inside the group
popover is a real text field now, which is also where its placeholder moved to.
Every input in the new/edit-connection dialog was a label div sharing one focus
handle, with a `focused_field` enum routing keystrokes to whichever draft string
was "active". Clicking a field only moved that enum, so there was no caret, no
selection, no pointer positioning, no composition, and password masking was a
string of bullets rebuilt on each render.
Each text input is now a `TextField` entity, built when the editor opens and
cleared when it closes, so the widgets live exactly as long as the draft they
mirror. `ConnectionEditorState` stays a plain value the runtime can clone and
save from; the fields write back through their subscriptions. The password field
masks itself while the buffer keeps the secret in the clear, which is what the
save path needs. The folder-name editor gets the same treatment.
Two focus fixes fall out of this. The editor surface's blanket `on_click` focus
grab is gone: it existed to keep the old fake inputs "focused", and would now
steal focus back from whichever field the pointer just landed on, since click
follows mouse-down. And because focus is per-window, the detached editor window
claims the name field itself on its first frame rather than trusting what the
main window focused.
The labelled row is the hit target, not just the one text line inside it, and it
takes its ring from the widget's own focus rather than from an enum nothing
updates any more.
Every "input" in the app is a label div: a `div` that takes focus, swallows key
events and prints the draft string. There is no caret, no selection, no pointer
positioning and no composition, so the moment anyone tries to edit rather than
append it reads as broken — which is exactly the complaint about the filter box.
`TextField` is a real GPUI widget instead. It is an `Entity` that owns its
buffer and focus, implements `EntityInputHandler` so the platform routes IME and
clipboard through it, and paints through a custom `Element` that shapes the line
once and reuses that shaping for hit-testing, the selection quads and the caret.
Owners learn about edits by subscribing to `TextFieldEvent::Changed`, which
carries the new content, rather than reading back through the entity.
It deliberately does not claim Enter, Escape, Tab or the arrows: those are
dialog and list gestures, so they stay unconsumed for the owner's own key
handler. That is what lets the saved-connections filter keep driving result
navigation while the field handles the text.
The editing rules live in `nyaterm_core::TextEdit` — caret motion across
multi-byte boundaries, what counts as a word (hosts and paths make `.`, `-`, `/`
and `@` stops), and which end of a selection shift moves — so they are testable
without a window.
Adopting it in the filter box retires the placeholder caret drawn by splicing a
`|` into the display string, and the seven arms this panel had bolted onto
`NyaTermApp`'s `EntityInputHandler`; the widget owns its own composition now.
The filter box was a label `div` with a key handler: no caret, no IME, no
paste, no way to move the insertion point, and an identical border whether or
not it had focus — clicking it produced no visible change at all. It now
follows the pattern the app already uses for its one field with a cursor: a
`gpui::canvas` installs an `ElementInputHandler` over the field's bounds, the
`EntityInputHandler` on `NyaTermApp` gains an arm per method, and the focused
field takes the primary border. Editing is relative to the caret and steps by
character boundary — the previous `pop()` cut a byte off multi-byte input.
Reaching the field's focus state means threading `window` down to the panel
bodies, which every panel now has for the same purpose.
While a filter is active the box also drives the results: up/down walk them and
enter opens the active row, which is drawn with its own wash and ring so it
stays distinct from the selection. The active row is dropped as soon as the
filter stops matching it.
Folders now start collapsed. Seeding the expansion set with every group buried
the folder list; a filter instead opens the folders that still have hits and
restores the prior tree when it clears, once per keyword so collapsing an
auto-opened folder sticks.
The rest of the row work: names get the full width of a horizontally scrollable
list instead of wrapping inside a fixed-height row, the folder count sits
against the right edge, hover actions are connect and edit and appear
immediately rather than after the detail card's delay, and the detail card is a
real tooltip so it hangs outside the panel instead of covering the rows under
it — which retires the 350ms hover-intent machinery and its per-frame poll. The
top selection strip is gone; those actions live in the menus. A folder with no
connections is no longer swallowed by the empty state.
Right-click looked dead. The menu stored the `MouseDownEvent` position, which
is window-relative, then positioned itself `absolute()` inside a panel that is
both offset from the window origin and `overflow_hidden` — so it landed at the
wrong place and whatever hung past the panel edge was clipped, which in a
sidebar this narrow was usually all of it. Menus now go through
`deferred(anchored().position(..).snap_to_window_with_margin(..))`, which reads
the position against the window and paints outside the panel's clip rect.
A right-click on the list background had no menu at all; it now offers the
selection actions plus new connection, new folder and import.
"Move to group" was missing everywhere, leaving drag and drop as the only way
to reparent a connection. It hangs off the row menu, the background menu and
the header menu, moving the whole selection when the clicked row is part of it.
`move_connections_into_group` writes the new order once instead of persisting
and refreshing per connection, so the list is never observed half-moved.
The flyout is a *descendant* of the menu rather than a second overlay, so the
menu's `on_mouse_down_out` does not fire and tear the stack down as the pointer
enters it, and it flips to the left when the window edge is close. It lists the
folders indented by depth rather than nesting a hover cascade per level, which
matches the group dropdown in the connection editor.
Cycling the sort button only reordered connections: `sort_groups` ignored
`ConnectionSortMode` entirely, so folders stayed put and the tree looked
unsorted. It now branches on the mode exactly as `sort_connections` does.
Both comparisons also used ASCII-lowercase ordering, which puts
`192.168.142.100` before `192.168.142.13` — and host lists are most of what
this panel sorts. `nyaterm_core::natural_compare` compares digit runs by value
without parsing them, so an arbitrarily long run cannot overflow, and falls
back to the raw comparison when case folding ties so the order stays total.
`visible_connection_ids` follows the same rule, or Shift-range selection would
walk a different list than the one on screen; it also stops descending into
collapsed folders, whose rows are not reachable.
The button now shows which mode is active instead of hiding it in a tooltip:
the glyph switches to sort-by-alpha, flips vertically for Z-A, and takes the
primary tint while a name sort is on.
`Svg::paint` does `self.path.zip(style.text.color)`, and `compute_style` starts
from `Style::default()`, whose `text.color` is `None`. A parent's
`.text_color(..)` cascades to real text but never to an `svg()` child, so a
glyph without a colour of its own was skipped entirely at paint time — no
error, no fallback, just an invisible icon still taking up layout.
59 call sites were affected: the window minimise/maximise/close controls, tab
close buttons, the title bar, the quick-command toolbar, the transfer browser
and queue, tunnels, settings and the AI panels. Shared button helpers are fixed
first, so most sites are covered by ~10 functions. Where the parent brightened
the icon on hover, the pair becomes `.group(..)` plus `.group_hover(..)`, since
a `.hover()` refinement cannot reach a child either.
`scripts/check-icon-references.sh` now fails on an `svg()` whose chain has a
`.path(..)` but no `.text_color(..)`. It checks depth 0 only: a colour that
lives solely inside `.group_hover(.., |s| s.text_color(..))` still leaves the
icon invisible until the pointer is over it.
Three costs on the hot output path:
`SessionEncoding::decode` ran every chunk through the incremental decoder and
allocated a fresh `String`, even though UTF-8 sessions — the default and the
overwhelming majority — are already valid UTF-8. Valid input is now validated
and borrowed, with only the trailing bytes of a split multi-byte sequence
carried into the next chunk.
The transport event queue was drained on a fixed interval, so a dedicated
consumer thread traded latency for wakeups. It can now park on the producer's
push, bounded by a timeout so shutdown and periodic bookkeeping stay on
schedule; the UI tick path keeps the non-blocking drain.
Graphics and the terminal snapshot model shed the copies that were feeding
those two paths.
Icons were previously hand-added, so their provenance and licensing lived in
folklore and a mis-painted asset was invisible at runtime: GPUI's `svg()`
reduces a file to an alpha mask, and `img()` keeps its pixels, but nothing
complains when the two are swapped.
Assets are now described by `scripts/icons.manifest` and vendored by
`scripts/sync-icons.sh` from pinned upstream releases, keeping the tree
reproducible and reviewable. `icons/**` stays monochrome and `color/**` full
colour; `scripts/check-icon-references.sh` fails the build when a referenced
asset is missing or painted through the wrong element, and runs in CI.
The lookup tables move out of `formatting/` into a dedicated `features/icons`
module so a ~1000-line brand table is not rebuilt inside a render closure, and
saved connections gain `icon_auto_detect`, which fills in a blank icon from the
detected remote system without ever overwriting a deliberate choice.
Desktop dead-code warnings went 104 to 18. Name what the remaining 18
are, with the evidence that each is an unfinished feature rather than
cruft -- mostly tests covering code no UI reaches, and match arms that
still handle prompts nothing raises. This is a to-do list, not a
backlog of deletions.
command_search_draft and the terminal_surface_paint_count field were
only ever written; the live paint counter is the free function of the
same name. ProcessTableLabels lost its five column headers along with
process_table_header, and ConnectionIconDef lost the glyph fallback the
SVG path replaced.
Stopped short of the fields and variants that are not cruft: dropping
SnapshotPasswordPromptKind::CloudPush and friends would have deleted 35
live match arms that still know how to handle a prompt nothing raises
any more. That is an unfinished feature, not dead weight, and removing
it is a product call.
Eighteen more items rustc reported dead that the automated pass had to
skip: helpers whose only remaining mentions were pub use re-exports
(sorted_quick_commands, tunnel_mode_label, compact_transfer_job_row,
now dropped from their mod.rs lists too), and items whose names collide
with live ones elsewhere -- next, all, title, placeholder, line_count,
select, session_id, and a second icon_action_button in the process
table that shadows the live one in the connections list.
TerminalSelection::new was in that batch and is not dead; a generic name
made the reference scan miss decorations.rs and terminal_surface_entity.
It is kept. One doc comment pointing at the deleted terminal_line_element
now refers to the paint crate instead.
133 items that nothing reaches: the old left_*_panel / right_*_panel
render tree superseded by panel_body's *_view dispatch, the widget
helpers only it called, and the handlers only those widgets invoked.
Verified against the live paths first rather than trusting dead_code
alone: for example start_sftp_download_job survives only as a caller of
start_sftp_download_job_for_target, which the browser selection flow
uses directly.
Deleted iteratively, requiring that no source line outside the items
being removed mentions the name -- rustc's dead set is per compilation
unit, so an item dead in the lib build can still be used from cfg(test)
or re-exported.
ai.rs is down from 4,032 to 1,554 lines across providers, agent, risk
and settings, so drop it from the large-file table. Note why risk is
deliberately its own small module, and that serde default_* functions
constrain how far a settings type can move from them.
Move the twenty default_* serde backing functions, the AiSettings
Default impl, the legacy profile migration and the mask/merge secret
helpers into ai/settings.rs. ai.rs imports the default_* names back so
the serde attributes on the settings types still resolve.
Field defaults, the legacy profile migration and which fields count as
secrets are compatibility surface and are unchanged.
ai/risk.rs holds command risk classification -- the pattern lists, the
rm/dd special cases and the label mapping. These decide whether the
agent may run a command unattended, so keeping them in one small module
makes the rules reviewable on their own.
ai/agent.rs holds the agent loop protocol: the three provider tool
schemas, the reply parsers with their fallbacks, the execution policy
and the agent prompt builders.
The pattern lists, escalation rules, tool schemas, parse fallbacks and
execution policy are unchanged.