summaryrefslogtreecommitdiff
path: root/features.txt
blob: 86b2a834fd04feb9b457aa5af71e1788246e9c27 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
Clicking somewhere and starting editing mode leads to a desync between the click cursor and the edit mode cursor. For the user they should be the same thing
so if you click somewhere if the cursor moves there the edit cursor should also start there. If its not possible for the edit cursor to be there, then the selection
cursor must also reflect that and move to a place that is 'close' following some heuristic.

The gui backends should receive events from the mouse4 and mouse5 buttons, the ones from logitech on the thumb that works as page next and page previous on the browser by default. For pardes they should work like ctrl-o and ctrl-i . 

Add treesitter context, like lets say that the view is in the middle of the function and we cant se its start, the treesitter context should show on the topmost
editor line the declaration of the function, it should correctly show the line number and the code highlited, but it should have some tinting on the bg and maybe a
tiny border on the gui like the tagline has so it looks almost like its the tagline going down a few more rows. It should not just work for functions, but also for struct decls, modules, I'd say any nested declaration that makes sense and you should make sure moving the cursor to there works but it wont move the whole view, but just the cursor and when scrolling up the context declarations will be gradually removed and later you'll find the cursor, it must be seamless. This should be off by default and toggled via a new builtin that will show up on panes where treesitter is active.

The builtins that produce search results like the search and lsp actions and look for files should have some padding between the Location and the result on
the same like so that the Location results are vertically aligned. There should be a new builtin called LocationsConfig that will return the runtime config used
by those, like `LocationsConfig context:5 tscontext:on` it will return the structs fields like this and when running the builtin it will parse input in the same
format using the same comptime info like it used to print the current config, it will only update the keys that were passed. The treesittercontext option
will show the context like the previous paragraph, but since the lines shown are a subset of the file, the context should be shown in order on the output window (and the location for it should be optional configured by the locationsconfig too). There should also be a context options thats not related to treesitter, to show some lines above and below the match, like grep does. 

doing ctrl-o and ctrl-i moves the selection, it should not affect the selection, this is a bug.

on tty mode the mouse selection works but the pardes selection on that pane should follow it, the idea here is that we're trying to make it easier to move text 
between tty panes without changing the pardes semantics or adding anything extra, just neat tricks like this. Still, you might need to come up with something
to decrease the number of movements/interactions needed to do simple things like copy and pasting text from a terminal to another.


The 9p protocol needs to be simplified and made plan9 idiomatic. Stop leaning on ctl verbs: a name should be the operation. The filesystem should carry more
in stat metadata (real sizes, mtimes, qid versions so a client can poll for change cheaply) and use the real 9P operations for what is currently a ctl verb or a
magic read. cloud9's engine already exposes fs.Features{create, remove, wstat} and the editor backend simply never declared them, so /new creating a pane on
*read* becomes a Tcreate in /pane, del becomes Tremove, cleartag becomes a truncating write to tag, and clean/dirty, scroll/noscroll, mark/nomark and
addr=dot/dot=addr/limit=addr become their own files. What is left of ctl should be only the verbs with no file analog.

shift-esc stopped leaving raw tty mode on the sdl backend. Commit a624a56 moved raw tty keys to an early-return branch that only knew ctrl-B and bare Esc, so
shift-esc was forwarded to the child instead. It is the unconditional way out of tty mode and should stay that way.

On tty mode scrolling should work and not always move the focus down to follow a new line. The terminal only moves down to follow new output when the last line
is already at the bottom, like most terminals and editors. Typing and entering raw mode snap back to live output, so nobody types blind.

The different kinds of layout pardes can initialize (tty mode, file mode, the classic three-shell boot, and the others that already exist) should be an explicit
enum rather than a chain of ifs over tty_only/shells/file/missing. That part of the code needs to be more explicit. The bare tty startup should always start with
an empty text pane at the bottom, not focused.

Nested pardes detection stopped working. Spawning a subprocess should export an env variable holding the pardes pid, so a child can see it and call a Look on the
parent through the 9p mount instead of starting a second session; whether 9p is mounted is another env variable the child can check.

Builtins should call Msg with the builtin name when they are called, minus a few exceptions like Msg itself. This is gated on a verbose config that is on by
default and can be turned off.

The sr.ht upstream has a macos update pushed to it. Pull it, merge it into the tip here as its own change, then look at what the macos backend gained and port
those features to the sdl backend wherever it makes sense.

Config options should print symmetrically with the way they are set: instead of showing transparency with a colon and a percent sign it should read like
`Etcetera 70`, exactly what you would type to set it, and the same for every other setting. LocationsConfig should only open an output window with the current
config when it is called with no arguments.

Closing the last pane in a column should not delete the column: it should leave a new empty pane there instead. A column closes only when it is closed explicitly,
so if there is no builtin for that, add a Delcol that sits on the column tagline by default. And in every tagline the builtin that kills or closes the thing should
come last, which does not look like the case for panes right now.

Pasting regressed on the terminal: inside agent sessions text is being pasted as an image and simply does not work. Reproduce it first, then fix it.

The 9p skill documentation should teach interacting with a running pardes through the /mnt/9p mount with ordinary file tools — cat, echo, ls — as the normal
ad-hoc path, and fall back to the python client only when nothing is mounted.

Dead sessions are never removed from the posted-9P registry, which is a bug. A session that aborts or is killed leaves both its socket in $XDG_RUNTIME_DIR and its
symlink under the registry directory, so /mnt/9p/pardes lists entries that give Input/output error on access. postToRegistry only ever replaces its own name's
entry; nothing sweeps the rest. Posting should sweep the service directory and unlink every entry whose target refuses a connection, and the dangling socket with
it. cloud9 already classifies a refused connect as stale (post.zig Probe), and 9p_io.zig already has `alive`, which treats uncertainty as live -- that is the right
bias, so only a definite refusal reaps.

After all of the above, do an optimization pass on startup time for the gui and tty platforms: measure first, then optimize. build.zig already has a `perf` step
(gesture latency and bounded terminal stress, with --json and a --base baseline to compare against), so a startup measurement belongs there rather than in a new
harness, and the baseline files are how a regression gets caught later.

9p create semantics: doing an action on read is not how plan9 frames it. The canonical pattern is the clone file -- /net/tcp/clone -- where *opening* allocates the
object and reading the fid only tells you which one you got; acme's new/ctl is the same shape, and pardes's old /new already keyed its side effect on open. The
Tcreate that replaced it has a real wart: a pane is named by a server-assigned serial, so the create ignores the client's name and `mkdir /pane/foo` leaves you
/pane/12. Replace it with /pane/clone (open allocates, read answers the serial) and keep Tremove, which is unambiguously right.

TODO, deferred: ligatures on the sdl backend. macOS got them with the first-class-host commit because CoreText shapes for free; sdl rasterises one glyph per
codepoint through FreeType (src/gui/font.c calls FT_Get_Char_Index then FT_Load_Glyph) and FreeType does no shaping, so this means adding HarfBuzz. It is not the
whole renderer, but it is most of the text path: the glyph atlas is keyed by codepoint (GlyphKey{codepoint, role, decoration}) and a ligature has no codepoint, so
the cache rekeys to glyph indices; a shaping stage has to run over runs of same-styled text within a row; and the per-cell instance emit has to place one glyph
across several columns and remember which columns a ligature consumed (macOS does this with ligatureShaped/ligatureCovered). The GPU batching, atlas upload and
scene shaders are untouched. Not worth it until something else wants a shaper.

Not a bug: taglines sit one pixel bottom-heavy when cell_h - tagline_h is odd, because taglineBandOffset splits the spare with a truncating /2. Confirmed
acceptable; leave it.

Startup, measured before optimizing (zig build perf -- --startup, plus timings of the installed binaries):
  core Pardes.init + first frame   0.22 ms (tty) .. 0.30 ms (classic)  -- negligible
  dynamic linker                   ~25 us, 346 relocations, 4 libs     -- negligible
  page faults for `pardes --version`  435                              -- the 812 MB binary is never paged in, size is a red herring
  pardes --version                 22 ms real, 21 ms USER, 1 ms sys
  pardes-gui --version             107 ms real, 23 ms user             -- ~84 ms blocked, not CPU
  /bin/true control                1 ms real, 0 user                   -- the harness costs nothing
Cause found, by profiling rather than guessing (perf record on `pardes --version`):

  start.main -> mem.Allocator.alloc
    -> heap.debug_allocator.DebugAllocator(.{ .stack_trace_frames = 6, ... }).alloc
      -> collectStackTrace -> debug.captureCurrentStackTrace
        -> SelfInfo.Elf.unwindFrame -> loadUnwindSections -> Dwarf.Unwind.prepare
          -> mem.sortUnstable  (22% of all samples sit in mem.swap under the pdq sort)

Zig's own start.main allocates through the Debug-mode DebugAllocator, which captures a 6-frame trace on every allocation,
and the first capture has to parse AND SORT the DWARF unwind tables of an 800 MB binary. That is the whole 18 ms. It is
not lockStderr (added to a hello-world: free), not static initializers (the binary has no .init_array), not the dynamic
loader (25 us), and not paging (435 page faults). std/start.zig:694 hardcodes `DebugAllocator(.{})`, so there is no knob.

Controls: a bare std.process.Init hello-world is 3 ms; the ReleaseFast pardes-perf binary (81 MB, links libc, so
use_debug_allocator is false) starts in 4 ms; /bin/true is 1 ms. The gui binary's one-off 107 ms first run was cold page
cache, not blocking -- warm it is 25 ms, the same as the tty one.

Recommendation: do NOT chase this. 22 ms warm is imperceptible for an editor, the only lever is the build mode, and
switching the default from Debug to ReleaseSafe would multiply an already ~10 minute build to save 18 ms of startup --
a bad trade when the build is the actual bottleneck in this project. Worth knowing for the day a binary ships to someone
else: ReleaseSafe keeps the safety checks, drops the debug allocator, and is 5x faster to start and 10x smaller.

Refactor Msg, and the other transient text a builtin shows (prompts for input and the like), to use the same line mechanism the treesitter context uses rather
than its own. Keep one piece of state on the pane tracking those lines, so several of them do not overlap, so they cooperate, and so removing one puts the rest
back where they belong. Keep it simple.

Startup, the number that actually matters: time to first paint, measured on the real binaries rather than on --version.

  pardes --tty on a pty      first byte 20 ms, settled 50 ms (8.3 KB in 12 bursts) -- unchanged with LSP enabled
  pardes-gui to window map   ~330 ms warm, of which ~95% is CPU and only ~30 ms is waiting

The ~1 s that gets noticed is the gui, and ~330 ms of it is real (the rest is presumably paint and content after the map).
Profiled warm (perf record, killed pre-map), by shared object: pardes-gui 28.5%, libc 20.7%, ld-linux 11.9%, libudev 10.3%,
nvidia glcore+eglcore+glsi+gpucomp ~21%, xkbcommon 2%. Cutting across those, DWARF unwinding is 20.9% of all samples --
the Debug allocator's per-allocation stack capture again, now the single largest identifiable slice, bigger than any one
driver library. An earlier measurement of ~510 ms was my own artifact: the probe redirected HOME, so ~/.cache/nvidia was
cold and the driver recompiled shaders every run.

Tried and reverted: deferring SDL_INIT_GAMEPAD until after the first frame, on the theory that libudev's 10% was device
enumeration for a gamepad most sessions do not have. Measured: window map unchanged (320-366 ms before and after) and
libudev only moved 10.3% -> 9.6%. The udev cost belongs to SDL_INIT_VIDEO enumerating Wayland seats and input, not to the
gamepad subsystem. Two fields and a branch for nothing, so it went back out.

Conclusion for both platforms: nothing in pardes's own logic is slow at startup. The cost is the Debug build's allocator
tracing and the GPU/driver stack coming up, and the only lever on either is the build mode.

The Last builtin should fall back to a heuristic when the jumplist has nothing to offer, so the bare tty layout -- a shell with an unfocused text pane under it --
alternates with Last even though that text pane was never focused and is not on the jumplist.

Before finishing: remove the zig cache, then build ReleaseSafe with the full tree-sitter options, install to ~/.local/bin, and make sure the desktop entry and anything else that launches
pardes point at the updated binaries.

Design smell, and it is a real one: Pardes.pump serialises input, event processing, 9P servicing, rendering and presenting into one loop, so the frame rate ends up
governing things that have nothing to do with drawing. The frame rate should only govern rendering. Evidence: a 9P RPC against an idle gui session costs 19.6 ms
and ZERO editor CPU -- it is not doing work, it is sleeping out the 16 ms SDL_WaitEventTimeout because the cross-thread wake does not wake it, and 9P is only
serviced afterwards in poll_frame. The same RPC against a tty session, same cloud9 runner, is 0.758 ms. So the server is fine and the gui host loop is the problem.

Caching optimisations for the gui: worth doing, the renderer rebuilds more per frame than it needs to.

FIXED, and it was the frame coupling after all: pump now draws a frame only when something that can change the screen happened. Pardes.needs_frame starts true,
is set by every event except a tick with nothing animating and a read-only fs_req, and is cleared once a frame is presented; pump returns before render/present
when it is false and nothing is animating. 9P round trip on a local socket: gui 9.7 ms -> 0.056 ms (173x), tty 0.758 ms -> 0.062 ms (12x). The wake path was never
the problem -- instrumentation proved every request woke the loop early (wakes=210, woke_early=212, timed_out=38 over 250 ticks) -- the reply simply could not be
produced until the loop had finished drawing a frame it did not need. Verified the gui still paints (screen shows the file, and a write through 9P redraws) and
the full suite is unchanged at 778/783 with the two known crashes.

Found by adversarial review and NOT fixed, because they are upstream or macOS-only rather than from this session's work:
- Surface.mark_hover is never assigned true anywhere in the tree, so the whole macOS hover ABI is inert: PARDES_CELL_HOVER is never emitted, encodeCellFlags always
  contributes 0, and PardesView's liquid-glass affordance never draws. The tests pass only because they set the flag by hand. The comment in the look_hover_preview
  paint block claims it carries those cells out to the hosts; it does not.
- -Dworkspace-tag is read only in src/macos.zig, and core.settings.workspace_tag is assigned only there, so the option builds cleanly for gui/tty/web and is
  silently ignored. (taglineBandOffset no longer takes it: every band is centred since 2026-09-23.) gui.zig still uses raw TOPBAR_H at five sites.
- The detached wire encodes the pointer shape in the frame header variant (full_link/diff_link), so the newer `.target` shape collapses to `.arrow` for an attached
  frontend. Fixing it means a new header variant, i.e. a protocol change.
- A shell that outlives its editor keeps PARDES_PID; if that pid is reused, `pardes <file>` now exits 1 instead of falling back to starting an editor. Exiting 1
  only when the socket itself refused would keep the old behaviour.

Notices are tagline bands at the TOP of the body now, not a row of body text at the bottom. Each message or leader prefix is emitted through the same
renderHeaderLayer the pane and column tags use, as a TagLayer of the new `.notice` kind, so it gets the tagline's height, its small-font metrics, its band
offset and its border for free -- none of which a body-grid row can have just by wearing a tagline font. The text is right aligned. The prompt stays on the
canonical grid because it owns a cursor, which has to sit on a real cell.

A notice is an OVERLAY, which is the one place it is NOT like the tree-sitter context: it takes no row from the body and moves no text. It is a chip as wide
as its own message plus a blank cell either side, anchored to the pane's right edge on the body's top rows, and the rest of each of those rows is ordinary
body text that still reads and still answers a click -- Notices.left records where each chip starts and the hit test refuses only the cells it covers. The
chip is measured in GRID columns rather than scaled into the tagline face it is drawn in, because the canonical grid is what a terminal client draws and a
chip narrower than its own text there would clip it; the narrower tagline face simply leaves a little more room inside the chip. The tag layer takes the
geometry the grid pass already chose, so the two cannot disagree and the GUI's "skip the cells a tag layer covers" leaves no stub behind.

Pardes.bodyTop(rect) is now the single answer to "where does the body begin", replacing fifteen copies of `if (tag_bottom) r.y else r.y + BOX_H` scattered
across the paint, hit-test, scroll, PDF and image paths -- which is what let the bands and the text under them come adrift in the first place.

Every notice is ALSO painted on the canonical grid, because the grid is what a terminal client draws and a band it cannot see is a message it never gets. The
GUI skips grid cells a tag layer covers, so nothing is drawn twice there.

Snapshot suite: 97/98. The goldens were several commits stale (Delcol on the column tagline, the closing word moved last in every
tagline, the bare-tty boot's empty pane, stacked Locations rows, the notice bands) and 17 scripts had stopped running at all. Fixed
by making the scripts say what they mean rather than by loosening them: `config <line>` is a new snapshot-script command that appends
to the per-script startup config, so a script that clicks body coordinates can pin `Verbose off` instead of counting the rows an
announcement band moves; `@word` column specs replace counted columns where a tagline word has moved; and two grids grew because a
pane tag is wider now than its `Del` could fit in.

Found by that pass and fixed: a watched file reloading under the editor changed the core WITHOUT going through `update`, so
`needs_frame` was never set and the reload was never drawn. `Pardes.invalidate()` is the name for "something changed me from
outside the event loop"; `reloadWatchedFile` and the theme reload call it.

Also from it: a toggle setting now SETS when given `on` or `off` and only flips when it is bare, so `writeReport`'s `Verbose on`
means what it says when fed back as configuration -- the symmetry the report was asked for, on the input side too.

Left failing, deliberately: `nested-optout`. See docs/divergences.md -- two levels of nesting prepend U+E016 (vaxis's F3) to typed
lines, which is a real bug in the key-forwarding path and not a stale expectation.

9P is answered on the connection's task now, not by the editor's loop. The loop used to be the only thing that could answer a
request, which made the editor's own syscalls through a mount of its own tree -- a Look at /mnt/9p/pardes/<me>/anything under a
`9ns --mntgen` view, a Save into it -- a request only the blocked loop could serve. The name-based refusal that followed
(ownMountSuffix) and the in-process routing of a mount of oneself (Client.sameSession) were patches over that, and both are gone.
What replaced them is one rule, `pardes.turn`: the core is single-threaded, the editor's thread has the turn by default and gives it
up in two kinds of gap -- while it waits for input (`rest`/`wake`, one site per shell) and while a step is out in a host syscall
(`yield`/`back`, inside the leaf calls in fs.zig and the few host-side ones: the file watcher's marks, a shell's cwd stat) -- and a
cloud9 connection task takes it in those gaps to answer. Which gap it is matters: waiting, anything goes; out in a syscall
mid-step, the core reads consistently but the step still holds pointers into it, so a request that would change a pane
(tree.needsQuiet: writes, truncations, /pane/new, rmdir, and the screen open because it renders) is parked in the engine with
Status.again and retried when the turn is next given up quiet. That needed cloud9's engine to park an open, a truncating wstat, a
clunk and a remove, not only reads and writes -- a parked job goes back into the job slot on retry, and a walk still cannot park
because its names live in the input frame. A changing request that queued effects waits (`awaitSettled`) until the editor's pump has
performed them before it is answered, so `echo Save > exec` still returns with the file written, the way `put` does in acme; and a
request settles the way a step does (`sync`, `fsReport`, `events.announce`), because without that a `/log` reader waited for the
user's next keystroke to hear about the pane the request made.

Two traps that cost real time. The first end-to-end run hung anyway: the Look completed on the connection task, then the editor woke
to perform the new pane's watch effect, and `inotify_add_watch` on a FUSE path is itself a request -- made while holding the turn.
The rule is therefore every host syscall on a user path, not fs.zig's alone; PDFs are read whole and opened from memory for the same
reason (MuPDF reads a file lazily at every page). The second: a parked write one byte longer than `park_data_max` fails EAGAIN, and
the client's largest write is msize less the 23-byte Twrite header, not less iohdrsz; a large Save to a peer's /os path came back
"save: Remote" until the engine's room was sized from a client. test/selfmount.py runs the editor under `9ns --mntgen` and Looks at,
reads and Saves its own tree through the mount; a unit test in 9p_io.zig pins that a change parks while the editor is out mid-step
and lands when it rests, while a read is answered in the window.

Performance, same machine, Debug builds, 9P over the Unix socket against a tty session: a read of /index 278us -> 61us and a pane
body 283us -> 64us, because a read no longer waits for the editor to wake; a truncating body write 1184us -> 609us; exec Msg 696us
-> 543us; pane/new + rmdir 1535us -> 1295us; exec Save 718us -> 583us. A change still costs one editor frame, since the editor
draws it before it rests again.

Left as they are: QUIC still runs on the editor's thread (cloud9's QUIC adapter is not std.Io based), so a QUIC client of a session
that Looks at its own QUIC-mounted tree would still wait on the loop; the Tty9p kernel mount lives in the helper's private
namespace, so a Look on `$PARDES_MOUNT/...` from that shell opens nothing in the editor -- no longer a deadlock, only a namespace
the editor is not in; and a message wider than its pane now keeps its tail rather than its head (msg.snap `msgtail`).

Two adversarial reviews of that design. The concurrency one found the lost wake for real: a connection task's own yield saved the
editor's mid-step "not quiet" and restored it after the editor had rested, so a resting editor looked busy forever and every change
parked with nobody to wake it -- `quiet` is now `out`, a count of steps out in a syscall from any thread, and nothing saves or
restores it. Behind it, three more: the editor could take its turn back while a connection task's step was still out (that step's
pointers dangling when it returned) -- `wake` now waits for the count to reach zero; the core's allocator was a stack-fallback
over a FixedBufferAllocator, thread-safe only in Debug where the DebugAllocator wrapped it -- the fixed buffer is taken through
its lock-free interface now; and a Restore let a task waiting on the old core wake after that core was freed -- `reset` swaps the
listener's core first and releases every waiter, which then finds the core changed and answers nothing. It also showed that
parking `pane/new` and `screen` would have hung a Look at exactly those two paths in one's own mount (the editor's own open would
park, waiting for a rest that cannot come), so those are answered mid-step: every yield sits before its step's mutation, so the
layout and the surface are whole under it. Images are read at open for the same reason PDFs are.

The rendering one found the chips drawn out from under: a placed image or PDF page is drawn after the cells (the GUI's image pass,
kitty's z-order), so a chip over a picture was painted over -- pictures give up the rows now, text keeps the overlay -- and in the
GUI a tree-sitter context band was emitted after the tag layers and painted over the chip, so body layers go first. A message is
one row of printable text (a language server's multi-line report read as one line with U+FFFD per newline), the 256-byte cut
never leaves half a glyph, and an over-wide message cut inside a wide glyph now drops that glyph rather than clipping its last.
The simplicity one cut the vestiges: `ninep_identity`, the restore's copy of listener addresses, `setWake`, the mailbox's
`Drained` name, the 64K read buffer, and the two macOS entry points whose multi-line signatures the wrapping had missed.