Compare commits
50 Commits
f5c81155ea
...
c6c5366098
| Author | SHA1 | Date | |
|---|---|---|---|
| c6c5366098 | |||
| 4789cf79bc | |||
| accbedbe4e | |||
| d7bf5edc1f | |||
| d5c31a92c5 | |||
| 2e60a0961c | |||
| 6271fac301 | |||
| f00eddcb2b | |||
| de3b5d436f | |||
| 7f6d37d4a3 | |||
| a9b4eff8a6 | |||
| 3df3b25839 | |||
| 5652845d2e | |||
| 9ef2f97423 | |||
| 062d0f1af9 | |||
| 27c4269a3d | |||
| c98c530c44 | |||
| 2277ed281d | |||
| 9f4c1539e4 | |||
| b83431aefc | |||
| 70d8582448 | |||
| 19f7354d1c | |||
| bdc9de0d1c | |||
| a96b9e8142 | |||
| 38c487f7c3 | |||
| d6ebf1cc06 | |||
| e486856f79 | |||
| 61a1f9b83e | |||
| 0d4d0ec425 | |||
| 9aafe0e01c | |||
| 5d7f239d34 | |||
| 86af6d774e | |||
| 70e64180c1 | |||
| 8c4d09f709 | |||
| f88f688726 | |||
| 629605bcdc | |||
| f75ee1ddb4 | |||
| e96d33edb9 | |||
| 616eea670b | |||
| e4d6837f78 | |||
| 5224fe4c6a | |||
| 264dedb47c | |||
| 7e639dd87f | |||
| 1d1ecf29d5 | |||
| 70ef989c2a | |||
| 30af1e97bc | |||
| a08ee02820 | |||
| 1468675792 | |||
| 047bae94af | |||
| b076d1a35a |
@@ -3,7 +3,7 @@
|
|||||||
## Authority
|
## Authority
|
||||||
|
|
||||||
- `PRODUCT.md` is the durable product orientation and product boundary for `mim`.
|
- `PRODUCT.md` is the durable product orientation and product boundary for `mim`.
|
||||||
- `REQUIREMENTS.md` is the checkable requirement authority. Cite rows as `req: component/id` in future code, tests, and design notes when a change implements or protects that requirement.
|
- `REQUIREMENTS.md` is the checkable requirement authority. Cite rows as `req: component/id` in future code, tests, and design notes when a change implements or protects that requirement. If implementation work changes durable product obligations, acceptance, safety/recovery behavior, or verification duties, update `REQUIREMENTS.md` in the same slice or explicitly report `REQUIREMENT IMPACT: none` with the reason.
|
||||||
- User instructions for the current task override these files when they conflict.
|
- User instructions for the current task override these files when they conflict.
|
||||||
|
|
||||||
## Product constraints
|
## Product constraints
|
||||||
@@ -38,6 +38,19 @@ zig build v1-smoke
|
|||||||
zig build run -- --help
|
zig build run -- --help
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Canonical terminal/browser E2E check for terminal rendering, raw input, panels,
|
||||||
|
KEYMAP workflows, LSP/tool workflow reachability, viewport/chrome/color behavior,
|
||||||
|
or NS10 work:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
zig build terminal-e2e
|
||||||
|
```
|
||||||
|
|
||||||
|
Run `terminal-e2e` after the regular build/test checks for behavior slices that
|
||||||
|
touch terminal UI, input, panel focus, workflow routing, or screenshot-visible
|
||||||
|
rendering. It writes ignored artifacts under `.zig-cache/terminal-e2e/`; a clean
|
||||||
|
source tree must remain clean after the command.
|
||||||
|
|
||||||
Canonical server install verification for a selected profile:
|
Canonical server install verification for a selected profile:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
|
|||||||
+3
-3
@@ -1,6 +1,6 @@
|
|||||||
# v1 iPhone SSH dogfood receipt
|
# v1 iPhone SSH dogfood receipt
|
||||||
|
|
||||||
Use this when closing #41. Do not mark the dogfood complete unless the edit was made from a real iPhone SSH terminal session.
|
Use this when closing #41 or NS8 hardening slice #56 under parent #43. Do not mark the dogfood complete unless the edit was made from a real iPhone SSH terminal session.
|
||||||
|
|
||||||
## Before the iPhone session
|
## Before the iPhone session
|
||||||
|
|
||||||
@@ -30,7 +30,7 @@ Pick one small real repository fix that can be judged without a desktop IDE:
|
|||||||
|
|
||||||
Do not use this receipt for a simulated fixture-only edit, a desktop terminal edit, or an edit made outside the iPhone SSH session.
|
Do not use this receipt for a simulated fixture-only edit, a desktop terminal edit, or an edit made outside the iPhone SSH session.
|
||||||
|
|
||||||
## Receipt to paste into #41
|
## Receipt to paste into #41 and/or #56
|
||||||
|
|
||||||
```text
|
```text
|
||||||
DOGFOOD RECEIPT
|
DOGFOOD RECEIPT
|
||||||
@@ -53,4 +53,4 @@ DOGFOOD RECEIPT
|
|||||||
|
|
||||||
## Close rule
|
## Close rule
|
||||||
|
|
||||||
Close #41 only when the pasted receipt confirms a real iPhone SSH edit and any blocking v1 findings are either fixed in committed code or explicitly deferred as non-blocking for PRODUCT.md v1 proof.
|
Close #41/#56 only when the pasted receipt confirms a real iPhone SSH edit and any blocking v1 findings are either fixed in committed code or explicitly deferred as non-blocking for PRODUCT.md v1 proof.
|
||||||
|
|||||||
@@ -0,0 +1,442 @@
|
|||||||
|
# KEYMAP.md
|
||||||
|
|
||||||
|
This is the durable v1 keymap and operation grammar for `mim`. It is design
|
||||||
|
authority, not a claim that every operation is implemented today. Implementation
|
||||||
|
slices should cite the relevant requirements and keep the grammar source-patched,
|
||||||
|
replayable, and terminal-trace-backed.
|
||||||
|
|
||||||
|
## Product stance
|
||||||
|
|
||||||
|
`mim` is mobile-first, not phone-only. The same command grammar must work when
|
||||||
|
SSHing from an iPhone software keyboard, a QWERTZ thumb keyboard, or an attached
|
||||||
|
physical keyboard. Physical keyboards may add faster direct keys, but they must
|
||||||
|
not become the only way to perform core editing, navigation, tool, or recovery
|
||||||
|
actions.
|
||||||
|
|
||||||
|
The keymap is optimized for:
|
||||||
|
|
||||||
|
- narrow terminal viewports;
|
||||||
|
- mnemonic commands that can be discovered from the screen;
|
||||||
|
- common operations as a mode plus one key, or at most a two-key sequence;
|
||||||
|
- an orthogonal grammar where verbs, objects, modes, panels, and tool operations
|
||||||
|
compose instead of becoming one-off bindings;
|
||||||
|
- avoiding required Esc/Ctrl/Alt/function-key chords for core actions;
|
||||||
|
- replayable terminal events separated from layout profiles and editor intents;
|
||||||
|
- code editing workflows, including diagnostics, LSP, lint, format, build/test,
|
||||||
|
and local trusted tools.
|
||||||
|
|
||||||
|
## Input layers
|
||||||
|
|
||||||
|
The input path has three distinct layers:
|
||||||
|
|
||||||
|
1. **Terminal event**: bytes or key events observed from the terminal.
|
||||||
|
2. **Layout profile**: source-patched keyboard tables backed by recorded traces
|
||||||
|
such as iOS QWERTZ and physical keyboard traces.
|
||||||
|
3. **Editor intent**: semantic operations such as `delete line`, `select indent`,
|
||||||
|
`format buffer`, or `next diagnostic`.
|
||||||
|
|
||||||
|
No command should inspect layout quirks directly. A new keyboard layout is a new
|
||||||
|
source-patched profile and trace fixture, not an ad-hoc branch in command code.
|
||||||
|
|
||||||
|
## Input capability tiers
|
||||||
|
|
||||||
|
Mobile SSH clients commonly expose more than a plain software keyboard: Escape,
|
||||||
|
Control, arrow keys, paste, and sometimes an attached hardware keyboard. The v1
|
||||||
|
keymap therefore treats these as capability tiers, not separate products:
|
||||||
|
|
||||||
|
1. **Core mobile tier**: commands reachable without required Esc/Ctrl/Alt,
|
||||||
|
function keys, number-row comfort, or symbol gymnastics. Space rails and
|
||||||
|
mnemonic letter aliases live here.
|
||||||
|
2. **Mobile SSH accelerator tier**: Esc, Ctrl, arrows, Home/End/Page, and paste
|
||||||
|
may accelerate the same intents when the SSH client exposes them. They must
|
||||||
|
never be the only recovery or core editing path.
|
||||||
|
3. **Attached keyboard tier**: Vim-style and terminal-standard direct keys such
|
||||||
|
as `gg`, `G`, `Ctrl-u`, `Ctrl-d`, `0`, `^`, `$`, and `.` are first-class when
|
||||||
|
available, but each core operation keeps a discoverable mobile fallback or
|
||||||
|
visible rail.
|
||||||
|
|
||||||
|
When adding a binding, choose the narrowest tier that makes the operation usable,
|
||||||
|
then document any accelerator aliases next to the canonical mobile path. If a
|
||||||
|
terminal trace shows a client cannot send an accelerator reliably, prefer a
|
||||||
|
source-patched layout/profile update or a visible fallback over a special-case
|
||||||
|
command branch.
|
||||||
|
|
||||||
|
## Orthogonality rules
|
||||||
|
|
||||||
|
The keymap should feel small because its parts compose:
|
||||||
|
|
||||||
|
- modes decide how raw input is interpreted;
|
||||||
|
- verbs decide the operation: delete, change, yank, select, format, explain;
|
||||||
|
- objects decide the range: word, line, indent block, delimiter pair, parameter,
|
||||||
|
diagnostic, function, panel item;
|
||||||
|
- counts/repetition decide how often a movement or previous operation applies;
|
||||||
|
- providers decide the source of language/tool data, not the user-facing command;
|
||||||
|
- panels decide presentation and filtering, not the operation semantics.
|
||||||
|
|
||||||
|
A new operation is suspect if it needs a bespoke binding that cannot reuse this
|
||||||
|
shape. Prefer adding one verb, one object, or one provider rule over adding many
|
||||||
|
special-case bindings. Reuse is not allowed to hide ambiguity: if the same key can
|
||||||
|
mean two things, the current mode or visible rail label must make the meaning
|
||||||
|
obvious.
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
- `d` + object deletes; `c` + object changes; `y` + object copies; `s` + object
|
||||||
|
selects. The object grammar is shared.
|
||||||
|
- `Space l` operates on language features; `Space t` operates on jobs/tools;
|
||||||
|
both can target buffer, selection, project, or provider without inventing a
|
||||||
|
separate mode.
|
||||||
|
- Formatting a selection, buffer, or project should be one format command plus a
|
||||||
|
scope/provider choice, not unrelated shortcuts.
|
||||||
|
|
||||||
|
## Counts and repetition
|
||||||
|
|
||||||
|
Counts and repetition are orthogonal modifiers, not separate commands for every
|
||||||
|
operation.
|
||||||
|
|
||||||
|
- A numeric prefix in Normal, Select, or Panel mode applies to the next movement,
|
||||||
|
object, or panel navigation where that operation is repeatable.
|
||||||
|
- Physical keyboards can type digits directly: `3 j`, `2 w`, `4 d w`.
|
||||||
|
- Mobile users can open the repeat rail with `Space r`, enter a small count in a
|
||||||
|
prompt, then choose the movement/object/operation. This avoids requiring a
|
||||||
|
comfortable number row for common editing.
|
||||||
|
- `.` repeats the last mutating edit when the key is available; `Space r .` is
|
||||||
|
the mobile canonical repeat-last path.
|
||||||
|
- Counts do not apply to unsafe broad tool operations such as format project,
|
||||||
|
fix-all, build, test, or external terminal commands unless a visible prompt
|
||||||
|
confirms the affected scope.
|
||||||
|
- A count is shown in the status/rail while pending and is cleared after one
|
||||||
|
operation, cancel, or mode switch.
|
||||||
|
|
||||||
|
## Modes
|
||||||
|
|
||||||
|
### Normal mode
|
||||||
|
|
||||||
|
Default navigation and command mode. Text input does not happen here except via
|
||||||
|
explicit symbol/punctuation insertion commands. Normal mode owns movement,
|
||||||
|
selection entry, leader rails, tool commands, save/quit, and recoverability.
|
||||||
|
|
||||||
|
### Insert mode
|
||||||
|
|
||||||
|
Text-entry mode for ordinary typing. `mim` starts in Normal mode; Insert mode is
|
||||||
|
entered explicitly with `i`, `a`, `o`, or another visible command. In Insert mode,
|
||||||
|
`Space` commits a literal space immediately and `Tab` inserts spaces by default
|
||||||
|
(expandtab, matching the dotfiles Neovim baseline). Escape returns to Normal;
|
||||||
|
Ctrl-[ is the same terminal byte and is documented as the Ctrl-capable SSH alias.
|
||||||
|
This preserves ordinary spaces and avoids reviving a Space-prefix escape chord.
|
||||||
|
If a client exposes neither Esc nor Ctrl-[, it is missing the mobile SSH
|
||||||
|
accelerator tier required for safe Insert-mode recovery; use the client's Esc
|
||||||
|
toolbar or an attached-keyboard path rather than overloading literal text input.
|
||||||
|
|
||||||
|
### Select mode
|
||||||
|
|
||||||
|
Selection-building mode. Escape or Ctrl-[ returns to Normal. Movement extends the
|
||||||
|
active selection by default. Vertical movement preserves a preferred cursor
|
||||||
|
column across ragged lines,
|
||||||
|
clamping only to the current line length until horizontal/editing motion resets
|
||||||
|
that preference. Semantic object selections live behind the select-object rail
|
||||||
|
(`s` then `o w`, `o l`, `o i`, `o p`, etc.) so bare `h/j/k/l`, word motions,
|
||||||
|
arrows, and mobile line aliases keep behaving as selection-extending motions.
|
||||||
|
Line object selection includes the line's newline boundary when present, matching
|
||||||
|
Vim-like linewise edit semantics. Select mode can apply operations such as
|
||||||
|
delete, replace, copy, format, code action, or explain to the selection.
|
||||||
|
|
||||||
|
### Panel mode
|
||||||
|
|
||||||
|
Transient-panel mode for file picker, project search, diagnostics, git, LSP,
|
||||||
|
build/test output, terminal escape hatch, and Pi/tool context. Panels share one
|
||||||
|
navigation grammar: move, open, preview, filter, act, close, and return.
|
||||||
|
|
||||||
|
### Prompt mode
|
||||||
|
|
||||||
|
Short text prompt mode for current-file search, project search, rename, command
|
||||||
|
filtering, and tool arguments. Prompt mode is not an ex-command language; it is a
|
||||||
|
small typed argument surface entered from a visible command.
|
||||||
|
|
||||||
|
## Leader keys and rails
|
||||||
|
|
||||||
|
`Space` is the primary leader in Normal mode. In Insert mode, Space inserts a
|
||||||
|
literal space immediately; terminals do not give `mim` reliable key-up/hold
|
||||||
|
semantics, so hold-to-leader is intentionally not a v1 path. The rail shows
|
||||||
|
mnemonic groups and available next keys. A command either executes after one key
|
||||||
|
or enters a second-level group; common paths should not go deeper than two keys
|
||||||
|
after the mode/leader.
|
||||||
|
|
||||||
|
`m` is the match/object prefix in Normal and Select modes. It is deliberately not
|
||||||
|
Vim `%`: `%` remains a physical-keyboard alias where available, while `m` is the
|
||||||
|
mobile mnemonic path.
|
||||||
|
|
||||||
|
`g` is the go/navigation prefix. It is used for jumps whose target is elsewhere:
|
||||||
|
definition, references, next/previous diagnostic, file symbol, project symbol,
|
||||||
|
and matching syntax target when a direct object prefix would be ambiguous.
|
||||||
|
|
||||||
|
`Space Space` reopens the last command rail/panel context. It is the universal
|
||||||
|
"where was I?" recovery path.
|
||||||
|
|
||||||
|
## Mnemonic command grammar
|
||||||
|
|
||||||
|
The grammar is verb-first for editing and noun/group-first for panels:
|
||||||
|
|
||||||
|
- `i`: insert;
|
||||||
|
- `a`: append/after;
|
||||||
|
- `o`: open/new line or open item depending on context;
|
||||||
|
- `s`: select/search depending on mode/rail group;
|
||||||
|
- `d`: delete;
|
||||||
|
- `c`: change;
|
||||||
|
- `r`: replace;
|
||||||
|
- `y`: yank/copy;
|
||||||
|
- `p`: paste/put;
|
||||||
|
- `u`: undo;
|
||||||
|
- `R`: redo where uppercase is available, otherwise `Space u` then `r`;
|
||||||
|
- `f`: find/format/file depending on rail group label;
|
||||||
|
- `l`: lint/LSP language tools depending on rail group label;
|
||||||
|
- `t`: tools/tasks/tests;
|
||||||
|
- `b`: buffer/build depending on rail group label;
|
||||||
|
- `q`: quit/close;
|
||||||
|
- `w`: write/save, shown as "write/save" in rails for mnemonic continuity.
|
||||||
|
|
||||||
|
Letter reuse is allowed only when the current mode or visible rail label makes
|
||||||
|
the meaning obvious. Invisible overloading is not allowed.
|
||||||
|
|
||||||
|
## Core Normal bindings
|
||||||
|
|
||||||
|
These are the mobile canonical paths. Physical keyboards may add direct aliases
|
||||||
|
such as arrow keys, Home/End, PageUp/PageDown, Escape, or `%`.
|
||||||
|
|
||||||
|
| Keys | Intent |
|
||||||
|
| --- | --- |
|
||||||
|
| `i` | enter Insert before cursor |
|
||||||
|
| `a` | enter Insert after cursor |
|
||||||
|
| `o` | open line below and enter Insert |
|
||||||
|
| `O` or `Space o` `a` | open line above and enter Insert |
|
||||||
|
| `h` `j` `k` `l` | left/down/up/right where comfortable; arrows are aliases |
|
||||||
|
| `w` `b` `e` | word forward/back/end |
|
||||||
|
| `gg` `G` | document start / bottom line |
|
||||||
|
| `Ctrl-u` `Ctrl-d` | half-page up/down when the SSH client exposes Ctrl; `g u` / `g d` are mobile fallback aliases |
|
||||||
|
| `H` `L` | mobile-reachable absolute line start/end aliases |
|
||||||
|
| `0` `^` `$` | attached/Vim-style line start/first-nonblank/end aliases; `^` jumps past leading spaces/tabs, not to column zero; `Space g` `l` opens line targets |
|
||||||
|
| `.` | repeat the last repeatable local edit (insert/change/delete/put/open-line subset) |
|
||||||
|
| `u` | undo |
|
||||||
|
| `Space u` `r` | redo |
|
||||||
|
| `Space w` | write/save current buffer |
|
||||||
|
| `Space q` | close/quit current surface, with dirty-buffer protection |
|
||||||
|
| `Space ?` | show contextual help/command rail |
|
||||||
|
| Normal `i` / `a` / `o` | enter Insert explicitly; `mim` starts in Normal mode |
|
||||||
|
|
||||||
|
## Editing operations
|
||||||
|
|
||||||
|
| Keys | Intent |
|
||||||
|
| --- | --- |
|
||||||
|
| `d` `d` | delete current line |
|
||||||
|
| `d` then motion/object | delete range |
|
||||||
|
| `c` `c` | change current line |
|
||||||
|
| `c` then motion/object | change range and enter Insert |
|
||||||
|
| `r` key/symbol | replace character or active selection |
|
||||||
|
| `s` | enter Select mode |
|
||||||
|
| `y` then motion/object | yank/copy range |
|
||||||
|
| `p` | paste/put after cursor or replace active selection |
|
||||||
|
| `Space p` | symbol insertion rail for coding punctuation: `p` `()`, `b` `[]`, `c` `{}`, `s` `/`, `f` `//`, `x` `*`, `a` `&`, `h` `#`, `d` `$`, `r` `@`, `v` `|`, `q` quotes, `e` single quotes, `t` backticks, `u` underscore |
|
||||||
|
| `Space p` `p` / `b` / `c` / `q` / `e` / `t` | insert paired delimiters or quotes, then place cursor inside |
|
||||||
|
|
||||||
|
Delete/change/yank all accept the same object grammar. This keeps operations
|
||||||
|
orthogonal: verbs decide what happens; objects decide what range is affected.
|
||||||
|
|
||||||
|
## Object and match grammar
|
||||||
|
|
||||||
|
Objects are shared by Normal and Select mode.
|
||||||
|
|
||||||
|
| Keys | Object or jump |
|
||||||
|
| --- | --- |
|
||||||
|
| `m` `m` | jump to matching delimiter or quote |
|
||||||
|
| `m` `s` | select inside matching delimiter/string pair |
|
||||||
|
| `m` `a` | select around matching delimiter/string pair |
|
||||||
|
| `m` `(` | jump/select `()` pair, depending on mode |
|
||||||
|
| `m` `{` | jump/select `{}` pair, depending on mode |
|
||||||
|
| `m` `[` | jump/select `[]` pair, depending on mode |
|
||||||
|
| `m` `"` / `m` `'` / `m` `` ` `` | jump/select quoted string where parseable |
|
||||||
|
| `s` then motion | enter Select mode and extend the selection with that motion |
|
||||||
|
| `s` `o` `w` | select word |
|
||||||
|
| `s` `o` `l` | select line |
|
||||||
|
| `s` `o` `i` | select current indent block |
|
||||||
|
| `s` `o` `p` | select current parameter/argument |
|
||||||
|
| `s` `o` `f` | select current function or syntactic enclosing form where available |
|
||||||
|
| `s` `o` `d` | select current diagnostic range |
|
||||||
|
|
||||||
|
Physical keyboards may expose `%` as an alias for `m m`. The mobile canonical
|
||||||
|
path stays `m m` because `%` is awkward on software keyboards and QWERTZ.
|
||||||
|
|
||||||
|
## Search and navigation
|
||||||
|
|
||||||
|
| Keys | Intent |
|
||||||
|
| --- | --- |
|
||||||
|
| `/` or `Space s` `f` | search in current file |
|
||||||
|
| `n` / `N` | next/previous search result; rail aliases exist if uppercase is awkward |
|
||||||
|
| `Space f` | file finder |
|
||||||
|
| `Space s` `p` | project text search |
|
||||||
|
| `Space s` `s` | symbol search in current file |
|
||||||
|
| `Space g` `d` | go to definition |
|
||||||
|
| `Space g` `r` | references |
|
||||||
|
| `Space g` `i` | implementation |
|
||||||
|
| `Space g` `t` | type definition |
|
||||||
|
| `Space g` `p` | previous cursor location |
|
||||||
|
| `Space g` `n` | next cursor location |
|
||||||
|
| `Space g` `e` | next diagnostic/error |
|
||||||
|
| `Space g` `E` or `Space g` `p` from diagnostics rail | previous diagnostic/error |
|
||||||
|
| `Space g` `a` | next parameter/argument in call |
|
||||||
|
| `Space g` `A` | previous parameter/argument in call |
|
||||||
|
|
||||||
|
Search results and navigation targets use transient panels on narrow viewports.
|
||||||
|
Opening a result returns to the editor without leaving behind permanent splits.
|
||||||
|
|
||||||
|
## Panels
|
||||||
|
|
||||||
|
`Space` plus a group opens a panel or rail:
|
||||||
|
|
||||||
|
| Keys | Surface |
|
||||||
|
| --- | --- |
|
||||||
|
| `Space f` | files panel / file tree / finder / recent |
|
||||||
|
| `Space a` or `Space P` | Pi/local trusted assistant/tool panel (`a` is the no-shift mobile path; `P` is the attached-keyboard mnemonic) |
|
||||||
|
| `Space s` | search: file/project/symbol |
|
||||||
|
| `Space d` | diagnostics |
|
||||||
|
| `g` rail | go/navigation (`gg`, `G`, `g u/d`, `g a/A`, diagnostics) |
|
||||||
|
| `Space l` | language/LSP tools |
|
||||||
|
| `Space j` or `Space t` | jobs/tasks: build/test/check/lint output and cancellation (`j` is the no-shift mobile path; `t` remains the task mnemonic) |
|
||||||
|
| `Space b` | buffers/build output depending on rail label |
|
||||||
|
| `Space v` | version control/git workbench |
|
||||||
|
| `Space !` | terminal escape hatch/job command |
|
||||||
|
| `Space y` or `Space p` | coding symbol rail (`y` is the no-shift alternate kept free for symbols; `p` is the historical/programming mnemonic) |
|
||||||
|
|
||||||
|
Panel controls are consistent:
|
||||||
|
|
||||||
|
| Key | Intent |
|
||||||
|
| --- | --- |
|
||||||
|
| `j`/`k` or arrows | move item |
|
||||||
|
| `o` or Enter | open/accept item |
|
||||||
|
| `p` | preview item |
|
||||||
|
| `/` | filter/search within panel |
|
||||||
|
| `a` | act on item; opens contextual action rail |
|
||||||
|
| `q` | close panel |
|
||||||
|
| `Space Space` | restore last panel/rail |
|
||||||
|
|
||||||
|
## Coding tools and language operations
|
||||||
|
|
||||||
|
Tool operations are first-class commands, not hidden side effects.
|
||||||
|
|
||||||
|
| Keys | Intent |
|
||||||
|
| --- | --- |
|
||||||
|
| `Space l` `h` | hover: compact card first, expandable panel for full text |
|
||||||
|
| `Space l` `s` | signature help: active parameter first, expandable panel for overloads/docs |
|
||||||
|
| `Space l` `n` / `Space l` `p` | next/previous parameter or argument placeholder; `g a`/`g A` are attached-keyboard accelerators for the same intent |
|
||||||
|
| `Space l` `a` | code actions for cursor/selection/diagnostic |
|
||||||
|
| `Space l` `r` | rename |
|
||||||
|
| `Space l` `o` | organize imports |
|
||||||
|
| `Space l` `f` | format current buffer |
|
||||||
|
| `Space l` `F` or `Space l` `w` | toggle/show format-on-save policy for this source build |
|
||||||
|
| `Space l` `d` | show provider details for symbol/diagnostic under cursor |
|
||||||
|
| `Space j`/`Space t` `l` | lint current file |
|
||||||
|
| `Space j`/`Space t` `L` | lint project/workspace |
|
||||||
|
| `Space j`/`Space t` `b` | build |
|
||||||
|
| `Space j`/`Space t` `t` | test |
|
||||||
|
| `Space j`/`Space t` `c` | check/typecheck |
|
||||||
|
| `Space d` | diagnostics panel |
|
||||||
|
| `Space d` `n` | next diagnostic |
|
||||||
|
| `Space d` `p` | previous diagnostic |
|
||||||
|
| `Space d` `s` | filter diagnostics by source/provider |
|
||||||
|
|
||||||
|
Format-on-save is a policy command, not a runtime config file. Defaults live in
|
||||||
|
source profiles. The rail must show whether save will format, which formatter or
|
||||||
|
LSP provider will run, and how to perform a one-shot save without formatting when
|
||||||
|
needed. If no formatter/linter/server exists, `mim` reports a clear missing-tool
|
||||||
|
or unsupported-capability diagnostic instead of installing anything.
|
||||||
|
|
||||||
|
### Hover, signature help, and long content
|
||||||
|
|
||||||
|
Hover responses are often too large for an iPhone terminal. The default answer is
|
||||||
|
not to dump all text inline.
|
||||||
|
|
||||||
|
`Space l h` shows a compact hover card first:
|
||||||
|
|
||||||
|
- symbol/type/signature headline;
|
||||||
|
- shortest useful documentation excerpt that fits the viewport;
|
||||||
|
- provider/source label when more than one provider could answer;
|
||||||
|
- `more`, `open`, `search`, `copy`, and `pin` actions in the rail.
|
||||||
|
|
||||||
|
Expanding hover opens a transient scrollable panel. The panel supports search,
|
||||||
|
line wrapping by terminal cell width, source/provider switching, copy/yank, and
|
||||||
|
closing back to the original cursor location. Long hovers never permanently steal
|
||||||
|
editor space, and they should not make diagnostics or signature context
|
||||||
|
unreachable.
|
||||||
|
|
||||||
|
Signature help follows the same rule: show the active overload and parameter
|
||||||
|
first; move between parameters with the language rail (`Space l n`/`Space l p`)
|
||||||
|
so hover, signature, and argument navigation are discoverable together. The Go
|
||||||
|
rail keeps `g a`/`g A` as attached-keyboard accelerators for the same movement.
|
||||||
|
Expand only when the user asks to read all overloads or full docs.
|
||||||
|
|
||||||
|
### Multiple providers
|
||||||
|
|
||||||
|
Several LSPs, linters, formatters, or build tools may apply to one buffer.
|
||||||
|
`mim` must keep provider identity visible when ambiguity matters:
|
||||||
|
|
||||||
|
- diagnostics carry source/provider and severity;
|
||||||
|
- hover and signature help show provider when multiple providers answer;
|
||||||
|
- formatting chooses a source-profile default and offers provider selection when
|
||||||
|
multiple formatters are available;
|
||||||
|
- code actions show provider/source in the action list;
|
||||||
|
- lint/build/test jobs show command/provider, status, exit code, and captured
|
||||||
|
diagnostics;
|
||||||
|
- provider conflicts are surfaced in the language rail instead of silently
|
||||||
|
choosing surprising behavior.
|
||||||
|
|
||||||
|
Safe default: use the source-profile-preferred provider when one exists; otherwise
|
||||||
|
ask through a transient provider picker for mutating operations such as format,
|
||||||
|
organize imports, rename, or fix-all. Non-mutating operations may merge results
|
||||||
|
while preserving source labels.
|
||||||
|
|
||||||
|
## Discoverability and feedback
|
||||||
|
|
||||||
|
- Pausing after `Space`, `m`, `g`, `d`, `c`, `y`, or `s` shows the valid next
|
||||||
|
keys for the current mode and selection.
|
||||||
|
- Rails use verbs and nouns, not internal names: "format buffer", "lint file",
|
||||||
|
"select indent", "matching brace".
|
||||||
|
- Dangerous or broad operations show the affected scope before execution.
|
||||||
|
- Dirty-buffer, missing-tool, provider-conflict, and unsupported-profile states
|
||||||
|
appear as diagnostics/panel messages, not silent failures.
|
||||||
|
- Help is contextual: `Space ?` in Insert, Select, Normal, and Panel mode shows
|
||||||
|
different high-value commands.
|
||||||
|
|
||||||
|
## Implementation gap as of this design
|
||||||
|
|
||||||
|
Backed by current code/tests:
|
||||||
|
|
||||||
|
- source-patched symbol insertion and keyboard-layout acceptance fixtures;
|
||||||
|
- leader rail seed behavior and command-intent separation;
|
||||||
|
- transient panel/rendering foundations;
|
||||||
|
- protocol/session/job/LSP/diagnostic skeletons;
|
||||||
|
- `Space t` lint/build/test/check job rail backed by source-patched command profiles,
|
||||||
|
captured output panels, missing-tool/cancel/timeout rows, and jump/yank actions;
|
||||||
|
- physical-keyboard parity aliases for arrows, Home/End, PageUp/PageDown, Escape,
|
||||||
|
direct digit counts, `%` match jumping, and panel page keys while preserving
|
||||||
|
mobile Space paths;
|
||||||
|
- basic trace commands for open, insert, save, panels, diagnostics, repo files,
|
||||||
|
physical key aliases, and local context.
|
||||||
|
|
||||||
|
Design-only and needing future implementation slices:
|
||||||
|
|
||||||
|
- explicit modal state machine for Normal/Insert/Select/Panel/Prompt;
|
||||||
|
- orthogonal command parser for shared verb + object + scope/provider grammar;
|
||||||
|
- object grammar shared by delete/change/yank/select;
|
||||||
|
- matching delimiter and quote object behavior;
|
||||||
|
- select current indent block and syntactic enclosing form;
|
||||||
|
- current-file search prompt and result navigation;
|
||||||
|
- parameter navigation in function calls;
|
||||||
|
- compact-first hover/signature cards with expandable scroll/search panels;
|
||||||
|
- full provider arbitration for multiple LSP/tool sources;
|
||||||
|
- source-profile-backed format, organize-imports, code-action edit application;
|
||||||
|
- format-on-save policy and one-shot save-without-format;
|
||||||
|
- contextual rails for all prefixes listed in this document;
|
||||||
|
- explicit mobile-safe Normal/Insert transitions without required Esc/Ctrl/Alt.
|
||||||
|
|
||||||
|
Future slices should implement one vertical behavior at a time with replay or
|
||||||
|
headless tests: for example, `m m` delimiter jump, `s i` indent selection,
|
||||||
|
current-file search, or `Space l f` format buffer with provider diagnostics.
|
||||||
+2
-2
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## Product thesis
|
## Product thesis
|
||||||
|
|
||||||
`mim` is a mobile-first terminal code editor for SSH sessions from an iPhone. It runs as one server-side binary and is designed around narrow viewports, QWERTZ/thumb input, source patching, and real coding workflows rather than desktop Vim compatibility.
|
`mim` is a mobile-first terminal code editor for SSH sessions from an iPhone. It runs as one server-side binary and is designed around narrow viewports, QWERTZ/thumb input, attachable physical keyboards, source patching, and real coding workflows rather than desktop Vim compatibility.
|
||||||
|
|
||||||
## User job
|
## User job
|
||||||
|
|
||||||
@@ -14,7 +14,7 @@ The user opens Vim or Neovim over SSH and tolerates bindings, ex commands, split
|
|||||||
|
|
||||||
## Mechanism
|
## Mechanism
|
||||||
|
|
||||||
`mim` is protocol-first: the editor core owns buffers, selections, panels, jobs, diagnostics, and session state behind a local socket, while the terminal UI is the first thin client over that protocol. The UI provides a thumb-native leader-key command grammar, transient panels instead of desktop splits, UTF-8-aware editing, Tree-sitter highlighting, built-in LSP, git, file search/tree, project text search, build output, terminal, and optional Pi assistant integration. The socket path is exported in the session environment so trusted local tools like `pi` can inspect editor state or send explicit commands through the same small protocol. Defaults live in source and are changed by patching and rebuilding, not runtime config files.
|
`mim` is protocol-first: the editor core owns buffers, selections, panels, jobs, diagnostics, and session state behind a local socket, while the terminal UI is the first thin client over that protocol. The UI provides a thumb-native leader-key command grammar that remains efficient with an attached physical keyboard, transient panels instead of desktop splits, UTF-8-aware editing, Tree-sitter highlighting, built-in LSP, git, file search/tree, project text search, build output, terminal, and optional Pi assistant integration. The socket path is exported in the session environment so trusted local tools like `pi` can inspect editor state or send explicit commands through the same small protocol. Defaults live in source and are changed by patching and rebuilding, not runtime config files.
|
||||||
|
|
||||||
## Feature-complete boundary
|
## Feature-complete boundary
|
||||||
|
|
||||||
|
|||||||
+24
-2
@@ -12,23 +12,43 @@ Rows are redgate TSV requirements: `ring<TAB>id<TAB>summary [tag]`.
|
|||||||
## input
|
## input
|
||||||
|
|
||||||
0 001 Common editing and navigation commands SHALL be reachable without required Esc, Ctrl, Alt, or function keys. [mobile]
|
0 001 Common editing and navigation commands SHALL be reachable without required Esc, Ctrl, Alt, or function keys. [mobile]
|
||||||
0 002 The Space leader SHALL expose a visible command rail when the user pauses after pressing it. [mobile]
|
0 002 The Space leader SHALL expose a visible command rail in Normal mode, while Insert-mode Space SHALL insert a literal space immediately. [mobile]
|
||||||
1 003 Frequent coding punctuation SHALL have editor-native insertion paths for mobile keyboards. [mobile]
|
1 003 Frequent coding punctuation SHALL have editor-native insertion paths for mobile keyboards. [mobile]
|
||||||
1 004 Input handling SHALL separate terminal key events, keyboard layout profiles, and editor command intents. [mobile]
|
1 004 Input handling SHALL separate terminal key events, keyboard layout profiles, and editor command intents. [mobile]
|
||||||
2 005 Keyboard layout profiles SHALL be source-patched tables backed by recorded terminal traces, starting with iOS QWERTZ. [mobile]
|
2 005 Keyboard layout profiles SHALL be source-patched tables backed by recorded terminal traces, starting with iOS QWERTZ. [mobile]
|
||||||
|
1 006 Physical keyboard input SHALL be a first-class terminal path with discoverable shortcuts and no loss of mobile no-required-modifier command access. [mobile]
|
||||||
|
1 007 The v1 keymap SHALL keep modes, verbs, objects, counts/repetition, panels, and tool operations orthogonal so operations compose instead of multiplying bindings. [mobile]
|
||||||
|
1 008 Line start/end motions SHALL have mobile-reachable aliases in addition to attached/Vim-style symbol or digit keys, and first-nonblank line motion SHALL remain distinct from absolute line start. [mobile]
|
||||||
|
1 009 Insert-mode Tab SHALL expand to spaces by default. [mobile]
|
||||||
|
1 010 Select mode SHALL extend the active range with motions and keep semantic object selection behind an explicit object rail. [mobile]
|
||||||
|
1 011 Vertical line motions SHALL preserve a preferred cursor cell across ragged lines, clamped to each target line until horizontal/editing motion resets the preference. [mobile]
|
||||||
|
1 012 Line selections SHALL be able to include the line's newline boundary so linewise edits can preserve Vim-like newline semantics. [mobile]
|
||||||
|
1 013 Document start/end and half-page motions SHALL be reachable from Normal mode with Ctrl-capable SSH keys and documented mobile fallback aliases. [mobile]
|
||||||
|
1 014 Normal-mode dot repeat SHALL replay the last deterministic local edit for the supported insert/change/delete/put subset without recording panel, search, or external side effects. [mobile]
|
||||||
|
1 015 The keymap SHALL classify bindings into core mobile, mobile SSH accelerator, and attached-keyboard tiers so Esc/Ctrl/arrow-capable clients can be used without making those keys mandatory for core commands. [mobile]
|
||||||
|
1 016 Every attached-keyboard or Ctrl/Esc accelerator that performs a core editing, navigation, recovery, or panel action SHALL have a documented visible mobile fallback or rail path. [mobile]
|
||||||
|
1 017 Insert and Select mode SHALL return to Normal via Escape and Ctrl-[ as equivalent terminal events while preserving literal Insert-mode Space. [mobile]
|
||||||
|
1 018 Select mode SHALL keep bare movement keys as selection-extending motions and provide a non-conflicting mobile object-selection rail for word, line, indent, parameter, enclosing form, and diagnostic ranges. [mobile]
|
||||||
|
1 019 Hover, signature help, and parameter navigation SHALL share a discoverable language rail, with any attached-keyboard accelerator documented as an alias rather than the only path. [mobile]
|
||||||
|
1 020 Files, search, jobs, Pi/assistant, and symbol panels SHALL have documented top-rail mobile paths, with shifted or mnemonic aliases treated as accelerators rather than the only discoverable path. [mobile]
|
||||||
|
|
||||||
## ui
|
## ui
|
||||||
|
|
||||||
0 001 Narrow terminals SHALL use transient panels instead of permanent desktop splits. [mobile]
|
0 001 Narrow terminals SHALL use transient panels instead of permanent desktop splits. [mobile]
|
||||||
1 002 Files, git, diagnostics, LSP, build, terminal, and Pi surfaces SHALL share one panel navigation model. [panels]
|
1 002 Files, git, diagnostics, LSP, build, terminal, and Pi surfaces SHALL share one panel navigation model. [panels]
|
||||||
|
0 003 When the cursor moves beyond the visible horizontal or vertical viewport, the editor SHALL scroll enough to keep the cursor cell visible. [mobile]
|
||||||
|
0 004 The default visual theme SHALL preserve semantic color identity for syntax, selections, diagnostics, job/tool status, hover/signature/context surfaces, and panel chrome on mobile SSH terminals. [mobile]
|
||||||
|
0 005 Terminal color adaptation SHALL choose truecolor, 256-color, 16-color, or mono output from local terminal environment signals with no runtime config files. [mobile]
|
||||||
|
|
||||||
## coding
|
## coding
|
||||||
|
|
||||||
0 001 Buffer and rendering code SHALL preserve UTF-8 boundaries and terminal cell width semantics. [core]
|
0 001 Buffer and rendering code SHALL preserve UTF-8 boundaries and terminal cell width semantics. [core]
|
||||||
1 002 Tree-sitter syntax highlighting SHALL be built in with source-patched language tables. [syntax]
|
1 002 Tree-sitter syntax highlighting SHALL be built in with source-patched language tables. [syntax]
|
||||||
1 003 LSP support SHALL be built in and spawn existing language servers without installing them. [lsp]
|
1 003 LSP support SHALL be built in and spawn existing language servers without installing them. [lsp]
|
||||||
1 004 LSP hover and signature help SHALL use viewport-aware presentation that remains usable on narrow terminals. [lsp]
|
1 004 LSP hover and signature help SHALL use compact-first viewport-aware presentation with source labels, scrolling/search, and explicit expansion for long content. [lsp]
|
||||||
1 005 When syntax or LSP data is available, `mim` SHALL provide mobile-friendly navigation between parameters in function calls. [lsp]
|
1 005 When syntax or LSP data is available, `mim` SHALL provide mobile-friendly navigation between parameters in function calls. [lsp]
|
||||||
|
1 006 Lint, format, format-on-save, organize-imports, code-action, build, test, and check workflows SHALL be first-class editor commands rather than hidden side effects. [tools]
|
||||||
|
1 007 When multiple LSPs or tools answer the same request, `mim` SHALL preserve provider/source identity and require explicit selection for ambiguous mutating operations. [tools]
|
||||||
|
|
||||||
## repo
|
## repo
|
||||||
|
|
||||||
@@ -41,6 +61,8 @@ Rows are redgate TSV requirements: `ring<TAB>id<TAB>summary [tag]`.
|
|||||||
0 002 Every product slice SHALL include adversarial carry-their-weight verification for realistic failure, boundary, or regression cases. [test]
|
0 002 Every product slice SHALL include adversarial carry-their-weight verification for realistic failure, boundary, or regression cases. [test]
|
||||||
1 003 Protocol, headless session, replay, and fixture tests SHALL be preferred over brittle UI snapshots unless rendering behavior is the claim. [test]
|
1 003 Protocol, headless session, replay, and fixture tests SHALL be preferred over brittle UI snapshots unless rendering behavior is the claim. [test]
|
||||||
1 004 Test/support code SHALL not introduce runtime config, plugin, installer, telemetry, or product surfaces. [test]
|
1 004 Test/support code SHALL not introduce runtime config, plugin, installer, telemetry, or product surfaces. [test]
|
||||||
|
1 005 Terminal-visible behavior changes SHALL be verified by the terminal E2E matrix or by an explicitly narrower rendered-terminal proof. [test]
|
||||||
|
1 006 Generated E2E evidence artifacts SHALL stay under ignored build output and SHALL not dirty a clean source tree. [test]
|
||||||
|
|
||||||
## governance
|
## governance
|
||||||
|
|
||||||
|
|||||||
@@ -96,6 +96,11 @@ pub fn build(b: *std.Build) void {
|
|||||||
const smoke_cmd = b.addRunArtifact(smoke_tests);
|
const smoke_cmd = b.addRunArtifact(smoke_tests);
|
||||||
const smoke_step = b.step("v1-smoke", "Run cheap v1 end-to-end fixture suite");
|
const smoke_step = b.step("v1-smoke", "Run cheap v1 end-to-end fixture suite");
|
||||||
smoke_step.dependOn(&smoke_cmd.step);
|
smoke_step.dependOn(&smoke_cmd.step);
|
||||||
|
|
||||||
|
const terminal_e2e_cmd = b.addSystemCommand(&.{ "python3", "tools/terminal_e2e.py" });
|
||||||
|
terminal_e2e_cmd.addArtifactArg(exe);
|
||||||
|
const terminal_e2e_step = b.step("terminal-e2e", "Run PTY-backed browser-terminal E2E checks");
|
||||||
|
terminal_e2e_step.dependOn(&terminal_e2e_cmd.step);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn addRunProfileStep(b: *std.Build, profile: Profile, exe: *std.Build.Step.Compile) void {
|
fn addRunProfileStep(b: *std.Build, profile: Profile, exe: *std.Build.Step.Compile) void {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const std = @import("std");
|
const std = @import("std");
|
||||||
|
const lsp = @import("lsp.zig");
|
||||||
|
|
||||||
// Local v1 guardrails for SSH/iPhone use: visible rows instead of silent stalls.
|
// Local v1 guardrails for SSH/iPhone use: visible rows instead of silent stalls.
|
||||||
// req: testing/001, testing/002, testing/003, testing/004
|
// req: testing/001, testing/002, testing/003, testing/004
|
||||||
@@ -7,6 +8,120 @@ pub const max_search_rows: usize = 64;
|
|||||||
pub const process_stdout_limit: usize = 256 * 1024;
|
pub const process_stdout_limit: usize = 256 * 1024;
|
||||||
pub const process_stderr_limit: usize = 256 * 1024;
|
pub const process_stderr_limit: usize = 256 * 1024;
|
||||||
|
|
||||||
|
pub const SourceLabel = struct {
|
||||||
|
provider: ?[]const u8,
|
||||||
|
row: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Severity = enum {
|
||||||
|
err,
|
||||||
|
warning,
|
||||||
|
info,
|
||||||
|
hint,
|
||||||
|
|
||||||
|
pub fn parse(text: []const u8) !Severity {
|
||||||
|
if (std.mem.eql(u8, text, "error")) return .err;
|
||||||
|
if (std.mem.eql(u8, text, "err")) return .err;
|
||||||
|
if (std.mem.eql(u8, text, "warning")) return .warning;
|
||||||
|
if (std.mem.eql(u8, text, "info")) return .info;
|
||||||
|
if (std.mem.eql(u8, text, "hint")) return .hint;
|
||||||
|
return error.InvalidDiagnosticRow;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(self: Severity) []const u8 {
|
||||||
|
return switch (self) {
|
||||||
|
.err => "error",
|
||||||
|
.warning => "warning",
|
||||||
|
.info => "info",
|
||||||
|
.hint => "hint",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Diagnostic = struct {
|
||||||
|
provider: []const u8,
|
||||||
|
version: u64,
|
||||||
|
file: []const u8,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
severity: Severity,
|
||||||
|
message: []const u8,
|
||||||
|
|
||||||
|
pub fn stale(self: Diagnostic, document_version: u64) bool {
|
||||||
|
return self.version < document_version;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn rowAlloc(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
provider: []const u8,
|
||||||
|
version: u64,
|
||||||
|
file: []const u8,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
severity: Severity,
|
||||||
|
message: []const u8,
|
||||||
|
) ![]u8 {
|
||||||
|
if (provider.len == 0 or file.len == 0 or start > end) return error.InvalidDiagnosticRow;
|
||||||
|
return std.fmt.allocPrint(
|
||||||
|
allocator,
|
||||||
|
"diag:{s}:{d}:{s}:{d}:{d}:{s}:{s}",
|
||||||
|
.{ sanitizeToken(provider), version, severity.label(), start, end, sanitizeToken(file), sanitizeToken(message) },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parseRow(row: []const u8) !Diagnostic {
|
||||||
|
if (!std.mem.startsWith(u8, row, "diag:")) return error.InvalidDiagnosticRow;
|
||||||
|
var parts = std.mem.splitScalar(u8, row, ':');
|
||||||
|
_ = parts.next() orelse return error.InvalidDiagnosticRow;
|
||||||
|
const provider = parts.next() orelse return error.InvalidDiagnosticRow;
|
||||||
|
const version_text = parts.next() orelse return error.InvalidDiagnosticRow;
|
||||||
|
const severity_text = parts.next() orelse return error.InvalidDiagnosticRow;
|
||||||
|
const start_text = parts.next() orelse return error.InvalidDiagnosticRow;
|
||||||
|
const end_text = parts.next() orelse return error.InvalidDiagnosticRow;
|
||||||
|
const file = parts.next() orelse return error.InvalidDiagnosticRow;
|
||||||
|
const message = parts.rest();
|
||||||
|
const version = std.fmt.parseUnsigned(u64, version_text, 10) catch return error.InvalidDiagnosticRow;
|
||||||
|
const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return error.InvalidDiagnosticRow;
|
||||||
|
const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return error.InvalidDiagnosticRow;
|
||||||
|
if (provider.len == 0 or file.len == 0 or start > end) return error.InvalidDiagnosticRow;
|
||||||
|
return .{ .provider = provider, .version = version, .file = file, .start = start, .end = end, .severity = try Severity.parse(severity_text), .message = message };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn panelRowAlloc(allocator: std.mem.Allocator, row: []const u8, document_version: u64) ![]u8 {
|
||||||
|
const diagnostic = try parseRow(row);
|
||||||
|
const freshness = if (diagnostic.stale(document_version)) "stale" else "fresh";
|
||||||
|
return std.fmt.allocPrint(
|
||||||
|
allocator,
|
||||||
|
"diag:{s}:{s}:{s}:{d}-{d}:{s}:{s}",
|
||||||
|
.{ freshness, diagnostic.provider, diagnostic.severity.label(), diagnostic.start, diagnostic.end, diagnostic.file, diagnostic.message },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sanitizeToken(text: []const u8) []const u8 {
|
||||||
|
if (text.len == 0) return "unknown";
|
||||||
|
for (text) |byte| {
|
||||||
|
if (byte <= 0x20 or byte == ':' or byte == '|') return "invalid";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sourceLabel(row: []const u8) SourceLabel {
|
||||||
|
if (std.mem.startsWith(u8, row, "provider:")) {
|
||||||
|
return .{
|
||||||
|
.provider = lsp.providerIdFromRow(row) catch null,
|
||||||
|
.row = lsp.providerPayloadFromRow(row) catch row,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return .{ .provider = null, .row = row };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn displayRowAlloc(allocator: std.mem.Allocator, row: []const u8) ![]u8 {
|
||||||
|
const labelled = sourceLabel(row);
|
||||||
|
if (labelled.provider) |provider| return std.fmt.allocPrint(allocator, "[{s}] {s}", .{ provider, labelled.row });
|
||||||
|
return allocator.dupe(u8, labelled.row);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn searchTruncatedRowAlloc(allocator: std.mem.Allocator, shown: usize, limit: usize) ![]u8 {
|
pub fn searchTruncatedRowAlloc(allocator: std.mem.Allocator, shown: usize, limit: usize) ![]u8 {
|
||||||
return std.fmt.allocPrint(allocator, "diagnostic:search_truncated:showing_{d}_of_limit_{d}", .{ shown, limit });
|
return std.fmt.allocPrint(allocator, "diagnostic:search_truncated:showing_{d}_of_limit_{d}", .{ shown, limit });
|
||||||
}
|
}
|
||||||
@@ -46,3 +161,19 @@ test "adversarial: unsafe diagnostic reasons are sanitized" {
|
|||||||
try std.testing.expectEqualStrings("unknown", sanitizeReason(""));
|
try std.testing.expectEqualStrings("unknown", sanitizeReason(""));
|
||||||
try std.testing.expectEqualStrings("spawn_error", sanitizeReason("spawn_error"));
|
try std.testing.expectEqualStrings("spawn_error", sanitizeReason("spawn_error"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: provider-labelled diagnostic rows display source identity" {
|
||||||
|
const labelled = sourceLabel("provider:zls:diagnostic:error:boom");
|
||||||
|
try std.testing.expectEqualStrings("zls", labelled.provider.?);
|
||||||
|
try std.testing.expectEqualStrings("diagnostic:error:boom", labelled.row);
|
||||||
|
|
||||||
|
const display = try displayRowAlloc(std.testing.allocator, "provider:zls:diagnostic:error:boom");
|
||||||
|
defer std.testing.allocator.free(display);
|
||||||
|
try std.testing.expectEqualStrings("[zls] diagnostic:error:boom", display);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: unlabelled diagnostic rows stay compatible" {
|
||||||
|
const labelled = sourceLabel("diagnostic:error:boom");
|
||||||
|
try std.testing.expect(labelled.provider == null);
|
||||||
|
try std.testing.expectEqualStrings("diagnostic:error:boom", labelled.row);
|
||||||
|
}
|
||||||
|
|||||||
@@ -18,6 +18,12 @@ pub const Key = enum {
|
|||||||
arrow_right,
|
arrow_right,
|
||||||
arrow_up,
|
arrow_up,
|
||||||
arrow_down,
|
arrow_down,
|
||||||
|
home,
|
||||||
|
end,
|
||||||
|
page_up,
|
||||||
|
page_down,
|
||||||
|
ctrl_u,
|
||||||
|
ctrl_d,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Event = union(enum) {
|
pub const Event = union(enum) {
|
||||||
@@ -82,6 +88,12 @@ pub fn normalize(raw: []const u8) Event {
|
|||||||
if (std.mem.eql(u8, raw, "\x1b[C")) return .{ .key = .arrow_right };
|
if (std.mem.eql(u8, raw, "\x1b[C")) return .{ .key = .arrow_right };
|
||||||
if (std.mem.eql(u8, raw, "\x1b[A")) return .{ .key = .arrow_up };
|
if (std.mem.eql(u8, raw, "\x1b[A")) return .{ .key = .arrow_up };
|
||||||
if (std.mem.eql(u8, raw, "\x1b[B")) return .{ .key = .arrow_down };
|
if (std.mem.eql(u8, raw, "\x1b[B")) return .{ .key = .arrow_down };
|
||||||
|
if (std.mem.eql(u8, raw, "\x1b[H") or std.mem.eql(u8, raw, "\x1b[1~") or std.mem.eql(u8, raw, "\x1b[7~")) return .{ .key = .home };
|
||||||
|
if (std.mem.eql(u8, raw, "\x1b[F") or std.mem.eql(u8, raw, "\x1b[4~") or std.mem.eql(u8, raw, "\x1b[8~")) return .{ .key = .end };
|
||||||
|
if (std.mem.eql(u8, raw, "\x1b[5~")) return .{ .key = .page_up };
|
||||||
|
if (std.mem.eql(u8, raw, "\x1b[6~")) return .{ .key = .page_down };
|
||||||
|
if (std.mem.eql(u8, raw, "\x15")) return .{ .key = .ctrl_u };
|
||||||
|
if (std.mem.eql(u8, raw, "\x04")) return .{ .key = .ctrl_d };
|
||||||
|
|
||||||
if (std.unicode.utf8ValidateSlice(raw) and isPrintableText(raw)) return .{ .text = raw };
|
if (std.unicode.utf8ValidateSlice(raw) and isPrintableText(raw)) return .{ .text = raw };
|
||||||
return .{ .unknown = raw };
|
return .{ .unknown = raw };
|
||||||
@@ -199,6 +211,12 @@ test "regular: raw terminal bytes normalize to stable editor input events" {
|
|||||||
try expectEventLabel("\x1b[C", "key:arrow_right");
|
try expectEventLabel("\x1b[C", "key:arrow_right");
|
||||||
try expectEventLabel("\x1b[A", "key:arrow_up");
|
try expectEventLabel("\x1b[A", "key:arrow_up");
|
||||||
try expectEventLabel("\x1b[B", "key:arrow_down");
|
try expectEventLabel("\x1b[B", "key:arrow_down");
|
||||||
|
try expectEventLabel("\x1b[H", "key:home");
|
||||||
|
try expectEventLabel("\x1b[1~", "key:home");
|
||||||
|
try expectEventLabel("\x1b[F", "key:end");
|
||||||
|
try expectEventLabel("\x1b[4~", "key:end");
|
||||||
|
try expectEventLabel("\x1b[5~", "key:page_up");
|
||||||
|
try expectEventLabel("\x1b[6~", "key:page_down");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "regular: captured trace can be replayed" {
|
test "regular: captured trace can be replayed" {
|
||||||
@@ -272,3 +290,15 @@ test "adversarial: replay names first event mismatch" {
|
|||||||
.ok => return error.ExpectedTraceDivergence,
|
.ok => return error.ExpectedTraceDivergence,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: Ctrl-[ normalizes as escape" {
|
||||||
|
try std.testing.expectEqual(Event{ .key = .escape }, normalize("\x1b"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: Ctrl-[ trace labels as escape" {
|
||||||
|
var recorder = TraceRecorder.init(std.testing.allocator);
|
||||||
|
defer recorder.deinit();
|
||||||
|
|
||||||
|
try recorder.capture("\x1b");
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, recorder.text(), "escape") != null);
|
||||||
|
}
|
||||||
|
|||||||
+136
@@ -13,8 +13,144 @@ pub const Error = error{
|
|||||||
InvalidCommand,
|
InvalidCommand,
|
||||||
InvalidJobRow,
|
InvalidJobRow,
|
||||||
InvalidPath,
|
InvalidPath,
|
||||||
|
MissingCurrentFile,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const Profile = enum {
|
||||||
|
lint_file,
|
||||||
|
lint_project,
|
||||||
|
build,
|
||||||
|
tests,
|
||||||
|
check,
|
||||||
|
|
||||||
|
pub fn name(self: Profile) []const u8 {
|
||||||
|
return switch (self) {
|
||||||
|
.lint_file => "lint_file",
|
||||||
|
.lint_project => "lint_project",
|
||||||
|
.build => "build",
|
||||||
|
.tests => "test",
|
||||||
|
.check => "check",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn provider(self: Profile) []const u8 {
|
||||||
|
return switch (self) {
|
||||||
|
.lint_file, .lint_project => "zig-fmt",
|
||||||
|
.build, .tests, .check => "zig-build",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn scope(self: Profile) []const u8 {
|
||||||
|
return switch (self) {
|
||||||
|
.lint_file => "file",
|
||||||
|
.lint_project, .build, .tests, .check => "project",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn argvForProfileAlloc(allocator: std.mem.Allocator, profile: Profile, current_file: ?[]const u8) ![][]const u8 {
|
||||||
|
var argv = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer {
|
||||||
|
for (argv.items) |arg| allocator.free(arg);
|
||||||
|
argv.deinit(allocator);
|
||||||
|
}
|
||||||
|
switch (profile) {
|
||||||
|
.lint_file => {
|
||||||
|
const path = current_file orelse return Error.MissingCurrentFile;
|
||||||
|
try validatePath(path);
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "zig"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "fmt"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "--check"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, path));
|
||||||
|
},
|
||||||
|
.lint_project => {
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "zig"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "fmt"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "--check"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "src"));
|
||||||
|
},
|
||||||
|
.build => {
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "zig"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "build"));
|
||||||
|
},
|
||||||
|
.tests => {
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "zig"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "build"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "test"));
|
||||||
|
},
|
||||||
|
.check => {
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "zig"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "build"));
|
||||||
|
try argv.append(allocator, try allocator.dupe(u8, "v1-smoke"));
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return argv.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn freeArgv(allocator: std.mem.Allocator, argv: []const []const u8) void {
|
||||||
|
for (argv) |arg| allocator.free(arg);
|
||||||
|
allocator.free(argv);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn profileRowsAlloc(allocator: std.mem.Allocator, io: std.Io, cwd: []const u8, profile: Profile, current_file: ?[]const u8) ![][]const u8 {
|
||||||
|
const argv = argvForProfileAlloc(allocator, profile, current_file) catch |err| {
|
||||||
|
if (err == Error.MissingCurrentFile) return missingCurrentFileRowsAlloc(allocator, profile);
|
||||||
|
return err;
|
||||||
|
};
|
||||||
|
defer freeArgv(allocator, argv);
|
||||||
|
return runRowsWithPreviewAlloc(allocator, io, cwd, argv, "job", profilePreview(profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn missingToolRowsAlloc(allocator: std.mem.Allocator, profile: Profile, tool: []const u8) ![][]const u8 {
|
||||||
|
var rows = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer freeOwnedRows(allocator, rows.items);
|
||||||
|
try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() }));
|
||||||
|
try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:status:missing_tool:{s}:no_install_attempted", .{sanitizeToken(tool)}));
|
||||||
|
return rows.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cancelRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 {
|
||||||
|
var rows = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer freeOwnedRows(allocator, rows.items);
|
||||||
|
try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() }));
|
||||||
|
try rows.append(allocator, try allocator.dupe(u8, "job:status:cancelled:user"));
|
||||||
|
return rows.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn timeoutRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 {
|
||||||
|
var rows = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer freeOwnedRows(allocator, rows.items);
|
||||||
|
try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() }));
|
||||||
|
try rows.append(allocator, try allocator.dupe(u8, "job:status:timeout:recoverable"));
|
||||||
|
return rows.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn missingCurrentFileRowsAlloc(allocator: std.mem.Allocator, profile: Profile) ![][]const u8 {
|
||||||
|
var rows = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer freeOwnedRows(allocator, rows.items);
|
||||||
|
try rows.append(allocator, try std.fmt.allocPrint(allocator, "job:profile:{s}:scope:{s}:provider:{s}", .{ profile.name(), profile.scope(), profile.provider() }));
|
||||||
|
try rows.append(allocator, try allocator.dupe(u8, "job:status:missing_current_file"));
|
||||||
|
return rows.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn profilePreview(profile: Profile) []const u8 {
|
||||||
|
return switch (profile) {
|
||||||
|
.lint_file => "profile:lint_file:scope:file:provider:zig-fmt",
|
||||||
|
.lint_project => "profile:lint_project:scope:project:provider:zig-fmt",
|
||||||
|
.build => "profile:build:scope:project:provider:zig-build",
|
||||||
|
.tests => "profile:test:scope:project:provider:zig-build",
|
||||||
|
.check => "profile:check:scope:project:provider:zig-build",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sanitizeToken(text: []const u8) []const u8 {
|
||||||
|
if (text.len == 0) return "unknown";
|
||||||
|
for (text) |byte| {
|
||||||
|
if (byte <= 0x20 or byte == ':' or byte == '|') return "invalid";
|
||||||
|
}
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
pub const Location = struct {
|
pub const Location = struct {
|
||||||
path: []const u8,
|
path: []const u8,
|
||||||
line: usize,
|
line: usize,
|
||||||
|
|||||||
+1
-1
@@ -162,7 +162,7 @@ pub fn classifyEvent(profile: *const Profile, event: input.Event) Classified {
|
|||||||
const kind: Kind = switch (event) {
|
const kind: Kind = switch (event) {
|
||||||
.key => |key| switch (key) {
|
.key => |key| switch (key) {
|
||||||
.space, .tab => .whitespace,
|
.space, .tab => .whitespace,
|
||||||
.enter, .backspace, .escape, .arrow_left, .arrow_right, .arrow_up, .arrow_down => .control,
|
.enter, .backspace, .escape, .arrow_left, .arrow_right, .arrow_up, .arrow_down, .home, .end, .page_up, .page_down, .ctrl_u, .ctrl_d => .control,
|
||||||
},
|
},
|
||||||
.text => |text| lookupFact(profile, text) orelse .unknown,
|
.text => |text| lookupFact(profile, text) orelse .unknown,
|
||||||
.unknown => .unknown,
|
.unknown => .unknown,
|
||||||
|
|||||||
+301
-17
@@ -13,14 +13,45 @@ test {
|
|||||||
pub const Feature = enum {
|
pub const Feature = enum {
|
||||||
search,
|
search,
|
||||||
panel_close,
|
panel_close,
|
||||||
|
lsp,
|
||||||
|
diagnostics,
|
||||||
|
jobs,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Action = union(enum) {
|
pub const Action = union(enum) {
|
||||||
none,
|
none,
|
||||||
save,
|
save,
|
||||||
quit,
|
quit,
|
||||||
|
force_quit,
|
||||||
open: []u8,
|
open: []u8,
|
||||||
symbol: symbol_mod.Symbol,
|
symbol: symbol_mod.Symbol,
|
||||||
|
file_picker,
|
||||||
|
pi_panel,
|
||||||
|
search_file,
|
||||||
|
search_project,
|
||||||
|
hover,
|
||||||
|
signature,
|
||||||
|
expand_hover,
|
||||||
|
language_format,
|
||||||
|
language_format_policy,
|
||||||
|
language_organize_imports,
|
||||||
|
language_code_actions,
|
||||||
|
language_parameter_next,
|
||||||
|
language_parameter_previous,
|
||||||
|
diagnostics_open,
|
||||||
|
diagnostics_next,
|
||||||
|
diagnostics_previous,
|
||||||
|
diagnostics_filter,
|
||||||
|
job_lint_file,
|
||||||
|
job_lint_project,
|
||||||
|
job_build,
|
||||||
|
job_test,
|
||||||
|
job_check,
|
||||||
|
job_cancel,
|
||||||
|
job_jump,
|
||||||
|
job_yank,
|
||||||
|
repeat_rail,
|
||||||
|
restore_last,
|
||||||
not_built: Feature,
|
not_built: Feature,
|
||||||
|
|
||||||
pub fn deinit(self: Action, allocator: std.mem.Allocator) void {
|
pub fn deinit(self: Action, allocator: std.mem.Allocator) void {
|
||||||
@@ -35,6 +66,10 @@ const Mode = enum {
|
|||||||
idle,
|
idle,
|
||||||
rail,
|
rail,
|
||||||
symbol_rail,
|
symbol_rail,
|
||||||
|
search_rail,
|
||||||
|
language_rail,
|
||||||
|
diagnostic_rail,
|
||||||
|
tool_rail,
|
||||||
open_prompt,
|
open_prompt,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +93,10 @@ pub const Leader = struct {
|
|||||||
.idle => return self.handleIdle(event),
|
.idle => return self.handleIdle(event),
|
||||||
.rail => return self.handleRail(event),
|
.rail => return self.handleRail(event),
|
||||||
.symbol_rail => return self.handleSymbolRail(event),
|
.symbol_rail => return self.handleSymbolRail(event),
|
||||||
|
.search_rail => return self.handleSearchRail(event),
|
||||||
|
.language_rail => return self.handleLanguageRail(event),
|
||||||
|
.diagnostic_rail => return self.handleDiagnosticRail(event),
|
||||||
|
.tool_rail => return self.handleToolRail(event),
|
||||||
.open_prompt => return self.handleOpenPrompt(event),
|
.open_prompt => return self.handleOpenPrompt(event),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -66,8 +105,12 @@ pub const Leader = struct {
|
|||||||
if (self.message) |message| return message;
|
if (self.message) |message| return message;
|
||||||
return switch (self.mode) {
|
return switch (self.mode) {
|
||||||
.idle => "",
|
.idle => "",
|
||||||
.rail => "leader: s save q quit o open p symbols / search x close",
|
.rail => "leader: w save q quit f files s search j jobs a/P Pi p/y symbols",
|
||||||
.symbol_rail => symbol_mod.rail_status,
|
.symbol_rail => symbol_mod.rail_status,
|
||||||
|
.search_rail => "search: f current file p project s symbols",
|
||||||
|
.language_rail => "language: h hover s sig n/p param f fmt o imports a actions",
|
||||||
|
.diagnostic_rail => "diagnostics: d open n/p next/prev f source (arrows/Enter ok)",
|
||||||
|
.tool_rail => "tools: l/L lint b build t test c check x cancel j jump y yank",
|
||||||
.open_prompt => "open: type path, Enter opens, Esc cancels",
|
.open_prompt => "open: type path, Enter opens, Esc cancels",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -84,6 +127,10 @@ pub const Leader = struct {
|
|||||||
return self.open_prompt.items;
|
return self.open_prompt.items;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn isPromptActive(self: *const Leader) bool {
|
||||||
|
return self.mode == .open_prompt;
|
||||||
|
}
|
||||||
|
|
||||||
fn handleIdle(self: *Leader, event: input.Event) Action {
|
fn handleIdle(self: *Leader, event: input.Event) Action {
|
||||||
self.message = null;
|
self.message = null;
|
||||||
switch (event) {
|
switch (event) {
|
||||||
@@ -100,27 +147,54 @@ pub const Leader = struct {
|
|||||||
self.message = null;
|
self.message = null;
|
||||||
switch (event) {
|
switch (event) {
|
||||||
.text => |text| {
|
.text => |text| {
|
||||||
if (std.mem.eql(u8, text, "s")) {
|
if (std.mem.eql(u8, text, "w")) {
|
||||||
self.mode = .idle;
|
self.mode = .idle;
|
||||||
return .save;
|
return .save;
|
||||||
}
|
}
|
||||||
|
if (std.mem.eql(u8, text, "s")) {
|
||||||
|
self.mode = .search_rail;
|
||||||
|
return .none;
|
||||||
|
}
|
||||||
|
if (std.mem.eql(u8, text, "l")) {
|
||||||
|
self.mode = .language_rail;
|
||||||
|
return .none;
|
||||||
|
}
|
||||||
|
if (std.mem.eql(u8, text, "d")) {
|
||||||
|
self.mode = .diagnostic_rail;
|
||||||
|
return .diagnostics_open;
|
||||||
|
}
|
||||||
|
if (std.mem.eql(u8, text, "t") or std.mem.eql(u8, text, "j")) {
|
||||||
|
self.mode = .tool_rail;
|
||||||
|
return .none;
|
||||||
|
}
|
||||||
if (std.mem.eql(u8, text, "q")) {
|
if (std.mem.eql(u8, text, "q")) {
|
||||||
self.mode = .idle;
|
self.mode = .idle;
|
||||||
return .quit;
|
return .quit;
|
||||||
}
|
}
|
||||||
|
if (std.mem.eql(u8, text, "Q")) {
|
||||||
|
self.mode = .idle;
|
||||||
|
return .force_quit;
|
||||||
|
}
|
||||||
if (std.mem.eql(u8, text, "o")) {
|
if (std.mem.eql(u8, text, "o")) {
|
||||||
self.open_prompt.clearRetainingCapacity();
|
self.open_prompt.clearRetainingCapacity();
|
||||||
self.mode = .open_prompt;
|
self.mode = .open_prompt;
|
||||||
return .none;
|
return .none;
|
||||||
}
|
}
|
||||||
if (std.mem.eql(u8, text, "p")) {
|
if (std.mem.eql(u8, text, "f")) {
|
||||||
|
self.mode = .idle;
|
||||||
|
return .file_picker;
|
||||||
|
}
|
||||||
|
if (std.mem.eql(u8, text, "P") or std.mem.eql(u8, text, "a")) {
|
||||||
|
self.mode = .idle;
|
||||||
|
return .pi_panel;
|
||||||
|
}
|
||||||
|
if (std.mem.eql(u8, text, "p") or std.mem.eql(u8, text, "y")) {
|
||||||
self.mode = .symbol_rail;
|
self.mode = .symbol_rail;
|
||||||
return .none;
|
return .none;
|
||||||
}
|
}
|
||||||
if (std.mem.eql(u8, text, "/")) {
|
if (std.mem.eql(u8, text, "r")) {
|
||||||
self.mode = .idle;
|
self.mode = .idle;
|
||||||
self.message = "search is not built in this profile yet";
|
return .repeat_rail;
|
||||||
return .{ .not_built = .search };
|
|
||||||
}
|
}
|
||||||
if (std.mem.eql(u8, text, "x")) {
|
if (std.mem.eql(u8, text, "x")) {
|
||||||
self.mode = .idle;
|
self.mode = .idle;
|
||||||
@@ -137,7 +211,10 @@ pub const Leader = struct {
|
|||||||
self.message = "leader cancelled";
|
self.message = "leader cancelled";
|
||||||
return .none;
|
return .none;
|
||||||
},
|
},
|
||||||
.space => return .none,
|
.space => {
|
||||||
|
self.mode = .idle;
|
||||||
|
return .restore_last;
|
||||||
|
},
|
||||||
else => {
|
else => {
|
||||||
self.mode = .idle;
|
self.mode = .idle;
|
||||||
self.message = "unknown leader key";
|
self.message = "unknown leader key";
|
||||||
@@ -152,6 +229,153 @@ pub const Leader = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handleLanguageRail(self: *Leader, event: input.Event) Action {
|
||||||
|
self.message = null;
|
||||||
|
switch (event) {
|
||||||
|
.text => |text| {
|
||||||
|
self.mode = .idle;
|
||||||
|
if (std.mem.eql(u8, text, "h")) return .hover;
|
||||||
|
if (std.mem.eql(u8, text, "s")) return .signature;
|
||||||
|
if (std.mem.eql(u8, text, "o")) return .language_organize_imports;
|
||||||
|
if (std.mem.eql(u8, text, "f")) return .language_format;
|
||||||
|
if (std.mem.eql(u8, text, "F")) return .language_format_policy;
|
||||||
|
if (std.mem.eql(u8, text, "w")) return .language_format_policy;
|
||||||
|
if (std.mem.eql(u8, text, "a")) return .language_code_actions;
|
||||||
|
if (std.mem.eql(u8, text, "n")) return .language_parameter_next;
|
||||||
|
if (std.mem.eql(u8, text, "p")) return .language_parameter_previous;
|
||||||
|
self.message = "language action is not built in this profile yet";
|
||||||
|
return .{ .not_built = .lsp };
|
||||||
|
},
|
||||||
|
.key => |key| switch (key) {
|
||||||
|
.escape, .backspace => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "language cancelled";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
else => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown language key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.unknown => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown language key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handleDiagnosticRail(self: *Leader, event: input.Event) Action {
|
||||||
|
self.message = null;
|
||||||
|
switch (event) {
|
||||||
|
.text => |text| {
|
||||||
|
if (std.mem.eql(u8, text, "d")) {
|
||||||
|
self.mode = .idle;
|
||||||
|
return .diagnostics_open;
|
||||||
|
}
|
||||||
|
if (std.mem.eql(u8, text, "n")) {
|
||||||
|
self.mode = .idle;
|
||||||
|
return .diagnostics_next;
|
||||||
|
}
|
||||||
|
if (std.mem.eql(u8, text, "p")) {
|
||||||
|
self.mode = .idle;
|
||||||
|
return .diagnostics_previous;
|
||||||
|
}
|
||||||
|
if (std.mem.eql(u8, text, "f")) {
|
||||||
|
self.mode = .idle;
|
||||||
|
return .diagnostics_filter;
|
||||||
|
}
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown diagnostics key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
.key => |key| switch (key) {
|
||||||
|
.escape, .backspace => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "diagnostics cancelled";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
else => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown diagnostics key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.unknown => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown diagnostics key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handleToolRail(self: *Leader, event: input.Event) Action {
|
||||||
|
self.message = null;
|
||||||
|
switch (event) {
|
||||||
|
.text => |text| {
|
||||||
|
self.mode = .idle;
|
||||||
|
if (std.mem.eql(u8, text, "l")) return .job_lint_file;
|
||||||
|
if (std.mem.eql(u8, text, "L")) return .job_lint_project;
|
||||||
|
if (std.mem.eql(u8, text, "b")) return .job_build;
|
||||||
|
if (std.mem.eql(u8, text, "t")) return .job_test;
|
||||||
|
if (std.mem.eql(u8, text, "c")) return .job_check;
|
||||||
|
if (std.mem.eql(u8, text, "x")) return .job_cancel;
|
||||||
|
if (std.mem.eql(u8, text, "j")) return .job_jump;
|
||||||
|
if (std.mem.eql(u8, text, "y")) return .job_yank;
|
||||||
|
self.message = "unknown tool key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
.key => |key| switch (key) {
|
||||||
|
.escape, .backspace => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "tools cancelled";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
else => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown tool key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.unknown => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown tool key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handleSearchRail(self: *Leader, event: input.Event) Action {
|
||||||
|
self.message = null;
|
||||||
|
switch (event) {
|
||||||
|
.text => |text| {
|
||||||
|
self.mode = .idle;
|
||||||
|
if (std.mem.eql(u8, text, "f")) return .search_file;
|
||||||
|
if (std.mem.eql(u8, text, "p")) return .search_project;
|
||||||
|
self.message = "search target is not built in this profile yet";
|
||||||
|
return .{ .not_built = .search };
|
||||||
|
},
|
||||||
|
.key => |key| switch (key) {
|
||||||
|
.escape, .backspace => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "search cancelled";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
else => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown search key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
.unknown => {
|
||||||
|
self.mode = .idle;
|
||||||
|
self.message = "unknown search key";
|
||||||
|
return .none;
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handleSymbolRail(self: *Leader, event: input.Event) Action {
|
fn handleSymbolRail(self: *Leader, event: input.Event) Action {
|
||||||
self.message = null;
|
self.message = null;
|
||||||
switch (event) {
|
switch (event) {
|
||||||
@@ -228,14 +452,14 @@ fn expectActionTag(expected: std.meta.Tag(Action), action: Action) !void {
|
|||||||
try std.testing.expectEqual(expected, std.meta.activeTag(action));
|
try std.testing.expectEqual(expected, std.meta.activeTag(action));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "regular: space opens a visible leader rail and save dispatches" {
|
test "regular: space opens a visible leader rail and write dispatches" {
|
||||||
var leader = Leader.init(std.testing.allocator);
|
var leader = Leader.init(std.testing.allocator);
|
||||||
defer leader.deinit();
|
defer leader.deinit();
|
||||||
|
|
||||||
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
try std.testing.expectEqualStrings("leader: s save q quit o open p symbols / search x close", leader.status());
|
try std.testing.expectEqualStrings("leader: w save q quit f files s search j jobs a/P Pi p/y symbols", leader.status());
|
||||||
|
|
||||||
try expectActionTag(.save, try leader.handleEvent(input.normalize("s")));
|
try expectActionTag(.save, try leader.handleEvent(input.normalize("w")));
|
||||||
try std.testing.expect(!leader.isActive());
|
try std.testing.expect(!leader.isActive());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -269,17 +493,18 @@ test "regular: leader open prompt collects UTF-8 path and backspace respects cod
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "regular: not-yet-built leader entries are explicit recoverable actions" {
|
test "regular: search rail opens current-file search and rejects other targets" {
|
||||||
var leader = Leader.init(std.testing.allocator);
|
var leader = Leader.init(std.testing.allocator);
|
||||||
defer leader.deinit();
|
defer leader.deinit();
|
||||||
|
|
||||||
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
const action = try leader.handleEvent(input.normalize("/"));
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("s")));
|
||||||
switch (action) {
|
try std.testing.expectEqualStrings("search: f current file p project s symbols", leader.status());
|
||||||
.not_built => |feature| try std.testing.expectEqual(Feature.search, feature),
|
try expectActionTag(.search_file, try leader.handleEvent(input.normalize("f")));
|
||||||
else => return error.ExpectedNotBuiltAction,
|
|
||||||
}
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
try std.testing.expectEqualStrings("search is not built in this profile yet", leader.status());
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("s")));
|
||||||
|
try expectActionTag(.search_project, try leader.handleEvent(input.normalize("p")));
|
||||||
}
|
}
|
||||||
|
|
||||||
test "adversarial: unknown leader key does not dispatch and recovers to idle" {
|
test "adversarial: unknown leader key does not dispatch and recovers to idle" {
|
||||||
@@ -360,3 +585,62 @@ test "adversarial: unknown and cancelled symbol rail inputs do not dispatch" {
|
|||||||
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b")));
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("\x1b")));
|
||||||
try std.testing.expectEqualStrings("symbol rail cancelled", leader.status());
|
try std.testing.expectEqualStrings("symbol rail cancelled", leader.status());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: leader exposes files and Pi panels" {
|
||||||
|
var leader = Leader.init(std.testing.allocator);
|
||||||
|
defer leader.deinit();
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "f files") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "P Pi") != null);
|
||||||
|
try expectActionTag(.file_picker, try leader.handleEvent(input.normalize("f")));
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try expectActionTag(.pi_panel, try leader.handleEvent(input.normalize("P")));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: language rail groups hover signature and parameter navigation" {
|
||||||
|
var leader = Leader.init(std.testing.allocator);
|
||||||
|
defer leader.deinit();
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("l")));
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "h hover") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "s sig") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "n/p param") != null);
|
||||||
|
try expectActionTag(.hover, try leader.handleEvent(input.normalize("h")));
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("l")));
|
||||||
|
try expectActionTag(.signature, try leader.handleEvent(input.normalize("s")));
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("l")));
|
||||||
|
try expectActionTag(.language_parameter_next, try leader.handleEvent(input.normalize("n")));
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("l")));
|
||||||
|
try expectActionTag(.language_parameter_previous, try leader.handleEvent(input.normalize("p")));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: top rail exposes unshifted panel aliases" {
|
||||||
|
var leader = Leader.init(std.testing.allocator);
|
||||||
|
defer leader.deinit();
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "f files") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "s search") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "j jobs") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "a/P Pi") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "p/y symbols") != null);
|
||||||
|
try expectActionTag(.pi_panel, try leader.handleEvent(input.normalize("a")));
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("j")));
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "tools:") != null);
|
||||||
|
try expectActionTag(.job_build, try leader.handleEvent(input.normalize("b")));
|
||||||
|
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
|
||||||
|
try expectActionTag(.none, try leader.handleEvent(input.normalize("y")));
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, leader.status(), "symbols:") != null);
|
||||||
|
}
|
||||||
|
|||||||
+406
@@ -19,8 +19,299 @@ pub const Error = error{
|
|||||||
InvalidEdit,
|
InvalidEdit,
|
||||||
InvalidEditRow,
|
InvalidEditRow,
|
||||||
InvalidHelp,
|
InvalidHelp,
|
||||||
|
DuplicateProvider,
|
||||||
|
InvalidProvider,
|
||||||
|
MissingProvider,
|
||||||
|
AmbiguousProvider,
|
||||||
|
StaleEdit,
|
||||||
|
OverlappingEdit,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub const ProviderKind = enum {
|
||||||
|
lsp,
|
||||||
|
linter,
|
||||||
|
formatter,
|
||||||
|
build,
|
||||||
|
test_runner,
|
||||||
|
checker,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ProviderCapability = enum {
|
||||||
|
diagnostics,
|
||||||
|
hover,
|
||||||
|
signature,
|
||||||
|
navigation,
|
||||||
|
format,
|
||||||
|
code_action,
|
||||||
|
lint,
|
||||||
|
build,
|
||||||
|
tests,
|
||||||
|
check,
|
||||||
|
rename,
|
||||||
|
organize_imports,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Provider = struct {
|
||||||
|
id: []const u8,
|
||||||
|
name: []const u8,
|
||||||
|
kind: ProviderKind,
|
||||||
|
capabilities: []const ProviderCapability,
|
||||||
|
preferred_for_mutation: bool = false,
|
||||||
|
document_version: u64 = 0,
|
||||||
|
alive: bool = true,
|
||||||
|
|
||||||
|
pub fn supports(self: Provider, capability: ProviderCapability) bool {
|
||||||
|
for (self.capabilities) |candidate| if (candidate == capability) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ProviderChoice = union(enum) {
|
||||||
|
missing,
|
||||||
|
selected: Provider,
|
||||||
|
ambiguous: []Provider,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ProviderRegistry = struct {
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
providers: std.ArrayList(Provider),
|
||||||
|
|
||||||
|
pub fn init(allocator: std.mem.Allocator) ProviderRegistry {
|
||||||
|
return .{ .allocator = allocator, .providers = .empty };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deinit(self: *ProviderRegistry) void {
|
||||||
|
self.providers.deinit(self.allocator);
|
||||||
|
self.* = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn register(self: *ProviderRegistry, provider: Provider) !void {
|
||||||
|
if (provider.id.len == 0 or provider.name.len == 0) return Error.InvalidProvider;
|
||||||
|
for (self.providers.items) |existing| {
|
||||||
|
if (std.mem.eql(u8, existing.id, provider.id)) return Error.DuplicateProvider;
|
||||||
|
}
|
||||||
|
try self.providers.append(self.allocator, provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn nonMutatingProvidersAlloc(self: *const ProviderRegistry, allocator: std.mem.Allocator, capability: ProviderCapability) ![]Provider {
|
||||||
|
var matches = std.ArrayList(Provider).empty;
|
||||||
|
errdefer matches.deinit(allocator);
|
||||||
|
for (self.providers.items) |provider| {
|
||||||
|
if (provider.alive and provider.supports(capability)) try matches.append(allocator, provider);
|
||||||
|
}
|
||||||
|
return matches.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn chooseMutatingProvider(self: *const ProviderRegistry, allocator: std.mem.Allocator, capability: ProviderCapability) !ProviderChoice {
|
||||||
|
const matches = try self.nonMutatingProvidersAlloc(allocator, capability);
|
||||||
|
if (matches.len == 0) {
|
||||||
|
allocator.free(matches);
|
||||||
|
return .missing;
|
||||||
|
}
|
||||||
|
var preferred: ?Provider = null;
|
||||||
|
for (matches) |provider| if (provider.preferred_for_mutation) {
|
||||||
|
if (preferred != null) return .{ .ambiguous = matches };
|
||||||
|
preferred = provider;
|
||||||
|
};
|
||||||
|
if (preferred) |provider| {
|
||||||
|
allocator.free(matches);
|
||||||
|
return .{ .selected = provider };
|
||||||
|
}
|
||||||
|
if (matches.len == 1) {
|
||||||
|
const provider = matches[0];
|
||||||
|
allocator.free(matches);
|
||||||
|
return .{ .selected = provider };
|
||||||
|
}
|
||||||
|
return .{ .ambiguous = matches };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn isStale(self: *const ProviderRegistry, provider_id: []const u8, document_version: u64) !bool {
|
||||||
|
for (self.providers.items) |provider| {
|
||||||
|
if (std.mem.eql(u8, provider.id, provider_id)) return document_version < provider.document_version;
|
||||||
|
}
|
||||||
|
return Error.MissingProvider;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn providerRowsAlloc(allocator: std.mem.Allocator, provider: Provider, rows: []const []const u8) ![][]const u8 {
|
||||||
|
var labelled = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer {
|
||||||
|
for (labelled.items) |row| allocator.free(row);
|
||||||
|
labelled.deinit(allocator);
|
||||||
|
}
|
||||||
|
for (rows) |row| {
|
||||||
|
try labelled.append(allocator, try std.fmt.allocPrint(allocator, "provider:{s}:{s}", .{ provider.id, row }));
|
||||||
|
}
|
||||||
|
return labelled.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn providerIdFromRow(row: []const u8) ![]const u8 {
|
||||||
|
if (!std.mem.startsWith(u8, row, "provider:")) return Error.InvalidProvider;
|
||||||
|
const rest = row[9..];
|
||||||
|
const split = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidProvider;
|
||||||
|
if (split == 0) return Error.InvalidProvider;
|
||||||
|
return rest[0..split];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn providerPayloadFromRow(row: []const u8) ![]const u8 {
|
||||||
|
if (!std.mem.startsWith(u8, row, "provider:")) return Error.InvalidProvider;
|
||||||
|
const rest = row[9..];
|
||||||
|
const split = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidProvider;
|
||||||
|
if (split == 0 or split + 1 > rest.len) return Error.InvalidProvider;
|
||||||
|
return rest[split + 1 ..];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn missingProviderRowAlloc(allocator: std.mem.Allocator, capability: ProviderCapability) ![]const u8 {
|
||||||
|
return std.fmt.allocPrint(allocator, "provider:missing:{s}", .{@tagName(capability)});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compactHoverCardAlloc(allocator: std.mem.Allocator, row: []const u8, width: usize) ![]u8 {
|
||||||
|
const provider = providerIdFromRow(row) catch "unknown";
|
||||||
|
const payload = providerPayloadFromRow(row) catch row;
|
||||||
|
const prefix = "lsp:hover:";
|
||||||
|
const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload;
|
||||||
|
const split = std.mem.indexOfScalar(u8, body, '|') orelse body.len;
|
||||||
|
const headline = body[0..split];
|
||||||
|
const docs = if (split < body.len) body[split + 1 ..] else "";
|
||||||
|
const budget = if (width > provider.len + headline.len + 8) width - provider.len - headline.len - 8 else 0;
|
||||||
|
const excerpt_len = @min(docs.len, budget);
|
||||||
|
return std.fmt.allocPrint(allocator, "[{s}] {s}{s}{s}", .{ provider, headline, if (excerpt_len > 0) " — " else "", docs[0..excerpt_len] });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expandedHoverRowsAlloc(allocator: std.mem.Allocator, row: []const u8, width: usize) ![][]const u8 {
|
||||||
|
const provider = providerIdFromRow(row) catch "unknown";
|
||||||
|
const payload = providerPayloadFromRow(row) catch row;
|
||||||
|
const prefix = "lsp:hover:";
|
||||||
|
const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload;
|
||||||
|
var rows = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer {
|
||||||
|
for (rows.items) |item| allocator.free(item);
|
||||||
|
rows.deinit(allocator);
|
||||||
|
}
|
||||||
|
try rows.append(allocator, try std.fmt.allocPrint(allocator, "hover_provider:{s}", .{provider}));
|
||||||
|
var start: usize = 0;
|
||||||
|
const wrap = @max(width, 8);
|
||||||
|
while (start < body.len) {
|
||||||
|
const end = @min(start + wrap, body.len);
|
||||||
|
const wrapped = try allocator.dupe(u8, body[start..end]);
|
||||||
|
for (wrapped) |*byte| {
|
||||||
|
if (byte.* <= 0x20 or byte.* == '|') byte.* = '_';
|
||||||
|
}
|
||||||
|
try rows.append(allocator, wrapped);
|
||||||
|
start = end;
|
||||||
|
}
|
||||||
|
return rows.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compactSignatureCardAlloc(allocator: std.mem.Allocator, row: []const u8) ![]u8 {
|
||||||
|
const provider = providerIdFromRow(row) catch "unknown";
|
||||||
|
const payload = providerPayloadFromRow(row) catch row;
|
||||||
|
const prefix = "lsp:signature:";
|
||||||
|
const body = if (std.mem.startsWith(u8, payload, prefix)) payload[prefix.len..] else payload;
|
||||||
|
return std.fmt.allocPrint(allocator, "[{s}] {s}", .{ provider, body });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const ProviderEdit = struct {
|
||||||
|
provider: []const u8,
|
||||||
|
capability: ProviderCapability,
|
||||||
|
version: u64,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
replacement: []const u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn providerCapabilityFromName(name: []const u8) !ProviderCapability {
|
||||||
|
if (std.mem.eql(u8, name, "format")) return .format;
|
||||||
|
if (std.mem.eql(u8, name, "organize_imports")) return .organize_imports;
|
||||||
|
if (std.mem.eql(u8, name, "code_action")) return .code_action;
|
||||||
|
return Error.InvalidProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn editRowAlloc(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
provider: []const u8,
|
||||||
|
capability: ProviderCapability,
|
||||||
|
version: u64,
|
||||||
|
start: usize,
|
||||||
|
end: usize,
|
||||||
|
replacement: []const u8,
|
||||||
|
) ![]u8 {
|
||||||
|
if (provider.len == 0 or start > end) return Error.InvalidEdit;
|
||||||
|
return std.fmt.allocPrint(allocator, "provider:{s}:edit:{s}:{d}:{d}:{d}:{s}", .{ provider, @tagName(capability), version, start, end, replacement });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parseProviderEditRow(row: []const u8) !ProviderEdit {
|
||||||
|
const provider = try providerIdFromRow(row);
|
||||||
|
const payload = try providerPayloadFromRow(row);
|
||||||
|
if (!std.mem.startsWith(u8, payload, "edit:")) return Error.InvalidEdit;
|
||||||
|
var parts = std.mem.splitScalar(u8, payload, ':');
|
||||||
|
_ = parts.next() orelse return Error.InvalidEdit;
|
||||||
|
const capability_name = parts.next() orelse return Error.InvalidEdit;
|
||||||
|
const version_text = parts.next() orelse return Error.InvalidEdit;
|
||||||
|
const start_text = parts.next() orelse return Error.InvalidEdit;
|
||||||
|
const end_text = parts.next() orelse return Error.InvalidEdit;
|
||||||
|
const replacement = parts.rest();
|
||||||
|
const start = std.fmt.parseUnsigned(usize, start_text, 10) catch return Error.InvalidEdit;
|
||||||
|
const end = std.fmt.parseUnsigned(usize, end_text, 10) catch return Error.InvalidEdit;
|
||||||
|
if (start > end) return Error.InvalidEdit;
|
||||||
|
return .{
|
||||||
|
.provider = provider,
|
||||||
|
.capability = try providerCapabilityFromName(capability_name),
|
||||||
|
.version = std.fmt.parseUnsigned(u64, version_text, 10) catch return Error.InvalidEdit,
|
||||||
|
.start = start,
|
||||||
|
.end = end,
|
||||||
|
.replacement = replacement,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validateEditBatch(rows: []const []const u8, document_version: u64, document_len: usize) !void {
|
||||||
|
var previous_start: ?usize = null;
|
||||||
|
var previous_end: ?usize = null;
|
||||||
|
for (rows) |row| {
|
||||||
|
const edit = try parseProviderEditRow(row);
|
||||||
|
if (edit.version != document_version) return Error.StaleEdit;
|
||||||
|
if (edit.end > document_len) return Error.InvalidEdit;
|
||||||
|
if (previous_start) |start| {
|
||||||
|
if (!(edit.end <= start or edit.start >= previous_end.?)) return Error.OverlappingEdit;
|
||||||
|
}
|
||||||
|
previous_start = edit.start;
|
||||||
|
previous_end = edit.end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn actionPanelRowAlloc(allocator: std.mem.Allocator, row: []const u8) ![]const u8 {
|
||||||
|
const edit = try parseProviderEditRow(row);
|
||||||
|
return std.fmt.allocPrint(allocator, "action:{s}:provider:{s}:scope:{d}-{d}:apply", .{ @tagName(edit.capability), edit.provider, edit.start, edit.end });
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn providerFromActionPanelRow(row: []const u8) ![]const u8 {
|
||||||
|
const marker = ":provider:";
|
||||||
|
const start = std.mem.indexOf(u8, row, marker) orelse return Error.InvalidProvider;
|
||||||
|
const rest = row[start + marker.len ..];
|
||||||
|
const end = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidProvider;
|
||||||
|
if (end == 0) return Error.InvalidProvider;
|
||||||
|
return rest[0..end];
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn capabilityFromActionPanelRow(row: []const u8) !ProviderCapability {
|
||||||
|
if (!std.mem.startsWith(u8, row, "action:")) return Error.InvalidProvider;
|
||||||
|
const rest = row[7..];
|
||||||
|
const end = std.mem.indexOfScalar(u8, rest, ':') orelse return Error.InvalidProvider;
|
||||||
|
return providerCapabilityFromName(rest[0..end]);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn providerPickerRowsAlloc(allocator: std.mem.Allocator, capability: ProviderCapability, providers: []const Provider) ![][]const u8 {
|
||||||
|
var rows = std.ArrayList([]const u8).empty;
|
||||||
|
errdefer {
|
||||||
|
for (rows.items) |row| allocator.free(row);
|
||||||
|
rows.deinit(allocator);
|
||||||
|
}
|
||||||
|
for (providers) |provider| {
|
||||||
|
try rows.append(allocator, try std.fmt.allocPrint(allocator, "provider:choose:{s}:{s}:{s}", .{ @tagName(capability), provider.id, provider.name }));
|
||||||
|
}
|
||||||
|
return rows.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
pub const Document = struct {
|
pub const Document = struct {
|
||||||
uri: []const u8,
|
uri: []const u8,
|
||||||
language_id: []const u8,
|
language_id: []const u8,
|
||||||
@@ -985,3 +1276,118 @@ test "adversarial: help payloads and non-call parameter moves fail safely" {
|
|||||||
try std.testing.expectEqualStrings("lsp:param:outside_call", moved.rows[0]);
|
try std.testing.expectEqualStrings("lsp:param:outside_call", moved.rows[0]);
|
||||||
try std.testing.expectEqual(@as(usize, 3), moved.cursor);
|
try std.testing.expectEqual(@as(usize, 3), moved.cursor);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: provider registry preserves source identity for non-mutating rows" {
|
||||||
|
var registry = ProviderRegistry.init(std.testing.allocator);
|
||||||
|
defer registry.deinit();
|
||||||
|
const caps = [_]ProviderCapability{ .diagnostics, .hover };
|
||||||
|
try registry.register(.{ .id = "zls", .name = "ZLS", .kind = .lsp, .capabilities = &caps, .document_version = 4 });
|
||||||
|
try registry.register(.{ .id = "lint", .name = "Lint", .kind = .linter, .capabilities = &[_]ProviderCapability{.diagnostics}, .document_version = 4 });
|
||||||
|
|
||||||
|
const providers = try registry.nonMutatingProvidersAlloc(std.testing.allocator, .diagnostics);
|
||||||
|
defer std.testing.allocator.free(providers);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), providers.len);
|
||||||
|
|
||||||
|
const rows_src = [_][]const u8{"lsp:diag:error:1:1:file.zig:bad"};
|
||||||
|
const rows = try providerRowsAlloc(std.testing.allocator, providers[0], &rows_src);
|
||||||
|
defer {
|
||||||
|
for (rows) |row| std.testing.allocator.free(row);
|
||||||
|
std.testing.allocator.free(rows);
|
||||||
|
}
|
||||||
|
try std.testing.expectEqualStrings("provider:zls:lsp:diag:error:1:1:file.zig:bad", rows[0]);
|
||||||
|
try std.testing.expectEqualStrings("zls", try providerIdFromRow(rows[0]));
|
||||||
|
try std.testing.expectEqualStrings("lsp:diag:error:1:1:file.zig:bad", try providerPayloadFromRow(rows[0]));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: mutating provider choice defaults to preferred or reports ambiguity" {
|
||||||
|
var registry = ProviderRegistry.init(std.testing.allocator);
|
||||||
|
defer registry.deinit();
|
||||||
|
try registry.register(.{ .id = "fmt-a", .name = "Formatter A", .kind = .formatter, .capabilities = &[_]ProviderCapability{.format} });
|
||||||
|
try registry.register(.{ .id = "fmt-b", .name = "Formatter B", .kind = .formatter, .capabilities = &[_]ProviderCapability{.format} });
|
||||||
|
|
||||||
|
var choice = try registry.chooseMutatingProvider(std.testing.allocator, .format);
|
||||||
|
switch (choice) {
|
||||||
|
.ambiguous => |providers| {
|
||||||
|
defer std.testing.allocator.free(providers);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), providers.len);
|
||||||
|
const picker = try providerPickerRowsAlloc(std.testing.allocator, .format, providers);
|
||||||
|
defer {
|
||||||
|
for (picker) |row| std.testing.allocator.free(row);
|
||||||
|
std.testing.allocator.free(picker);
|
||||||
|
}
|
||||||
|
try std.testing.expectEqualStrings("provider:choose:format:fmt-a:Formatter A", picker[0]);
|
||||||
|
},
|
||||||
|
else => return error.ExpectedAmbiguousProvider,
|
||||||
|
}
|
||||||
|
|
||||||
|
var preferred = ProviderRegistry.init(std.testing.allocator);
|
||||||
|
defer preferred.deinit();
|
||||||
|
try preferred.register(.{ .id = "fmt-a", .name = "Formatter A", .kind = .formatter, .capabilities = &[_]ProviderCapability{.format}, .preferred_for_mutation = true });
|
||||||
|
try preferred.register(.{ .id = "fmt-b", .name = "Formatter B", .kind = .formatter, .capabilities = &[_]ProviderCapability{.format} });
|
||||||
|
choice = try preferred.chooseMutatingProvider(std.testing.allocator, .format);
|
||||||
|
switch (choice) {
|
||||||
|
.selected => |provider| try std.testing.expectEqualStrings("fmt-a", provider.id),
|
||||||
|
else => return error.ExpectedSelectedProvider,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
test "adversarial: provider registry rejects duplicates reports missing and stale" {
|
||||||
|
var registry = ProviderRegistry.init(std.testing.allocator);
|
||||||
|
defer registry.deinit();
|
||||||
|
try registry.register(.{ .id = "zls", .name = "ZLS", .kind = .lsp, .capabilities = &[_]ProviderCapability{.diagnostics}, .document_version = 7 });
|
||||||
|
try std.testing.expectError(Error.DuplicateProvider, registry.register(.{ .id = "zls", .name = "Other", .kind = .lsp, .capabilities = &[_]ProviderCapability{.hover} }));
|
||||||
|
|
||||||
|
const missing_choice = try registry.chooseMutatingProvider(std.testing.allocator, .format);
|
||||||
|
switch (missing_choice) {
|
||||||
|
.missing => {},
|
||||||
|
else => return error.ExpectedMissingProvider,
|
||||||
|
}
|
||||||
|
const missing_row = try missingProviderRowAlloc(std.testing.allocator, .format);
|
||||||
|
defer std.testing.allocator.free(missing_row);
|
||||||
|
try std.testing.expectEqualStrings("provider:missing:format", missing_row);
|
||||||
|
|
||||||
|
try std.testing.expect(try registry.isStale("zls", 6));
|
||||||
|
try std.testing.expect(!(try registry.isStale("zls", 7)));
|
||||||
|
try std.testing.expectError(Error.MissingProvider, registry.isStale("none", 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: provider-labelled diagnostic rows retain original parser compatibility" {
|
||||||
|
const payload =
|
||||||
|
\\{"params":{"uri":"file:///a.zig","diagnostics":[{"range":{"start":{"line":0,"character":2}},"severity":1,"message":"boom"}]}}
|
||||||
|
;
|
||||||
|
const rows = try diagnosticRowsAlloc(std.testing.allocator, payload);
|
||||||
|
defer {
|
||||||
|
for (rows) |row| std.testing.allocator.free(row);
|
||||||
|
std.testing.allocator.free(rows);
|
||||||
|
}
|
||||||
|
const provider: Provider = .{ .id = "zls", .name = "ZLS", .kind = .lsp, .capabilities = &[_]ProviderCapability{.diagnostics} };
|
||||||
|
const labelled = try providerRowsAlloc(std.testing.allocator, provider, rows[1..]);
|
||||||
|
defer {
|
||||||
|
for (labelled) |row| std.testing.allocator.free(row);
|
||||||
|
std.testing.allocator.free(labelled);
|
||||||
|
}
|
||||||
|
const payload_row = try providerPayloadFromRow(labelled[0]);
|
||||||
|
const location = try diagnosticLocationFromRow(payload_row);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), location.line);
|
||||||
|
try std.testing.expectEqual(@as(usize, 3), location.character);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: provider rows label hover signature navigation actions and lint uniformly" {
|
||||||
|
const provider: Provider = .{ .id = "zls", .name = "ZLS", .kind = .lsp, .capabilities = &[_]ProviderCapability{ .hover, .signature, .navigation, .code_action, .lint } };
|
||||||
|
const raw = [_][]const u8{
|
||||||
|
"lsp:hover:headline:docs",
|
||||||
|
"lsp:signature:call(arg):active=1",
|
||||||
|
"lsp:definition:src/main.zig:10:4",
|
||||||
|
"lsp:code_action:fix:remove unused",
|
||||||
|
"lint:warning:src/main.zig:1:1:style",
|
||||||
|
};
|
||||||
|
const rows = try providerRowsAlloc(std.testing.allocator, provider, &raw);
|
||||||
|
defer {
|
||||||
|
for (rows) |row| std.testing.allocator.free(row);
|
||||||
|
std.testing.allocator.free(rows);
|
||||||
|
}
|
||||||
|
for (rows, raw) |row, expected_payload| {
|
||||||
|
try std.testing.expectEqualStrings("zls", try providerIdFromRow(row));
|
||||||
|
try std.testing.expectEqualStrings(expected_payload, try providerPayloadFromRow(row));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+415
-23
@@ -17,6 +17,8 @@ const session = @import("session.zig");
|
|||||||
const socket = @import("socket.zig");
|
const socket = @import("socket.zig");
|
||||||
const symbol = @import("symbol.zig");
|
const symbol = @import("symbol.zig");
|
||||||
const syntax = @import("syntax.zig");
|
const syntax = @import("syntax.zig");
|
||||||
|
const terminal_debug = @import("terminal_debug.zig");
|
||||||
|
const theme = @import("theme.zig");
|
||||||
const tui = @import("tui.zig");
|
const tui = @import("tui.zig");
|
||||||
|
|
||||||
pub const version = "0.1.0-dev";
|
pub const version = "0.1.0-dev";
|
||||||
@@ -25,6 +27,7 @@ const help_text =
|
|||||||
\\mim - mobile-first terminal code editor for SSH sessions
|
\\mim - mobile-first terminal code editor for SSH sessions
|
||||||
\\
|
\\
|
||||||
\\Usage:
|
\\Usage:
|
||||||
|
\\ mim [path]
|
||||||
\\ mim [--help]
|
\\ mim [--help]
|
||||||
\\ mim [--version]
|
\\ mim [--version]
|
||||||
\\ mim context [task] [intent]
|
\\ mim context [task] [intent]
|
||||||
@@ -32,25 +35,25 @@ const help_text =
|
|||||||
\\ mim mimctl context <socket|-> [task] [intent]
|
\\ mim mimctl context <socket|-> [task] [intent]
|
||||||
\\ mim --mimctl <socket|-> <request...>
|
\\ mim --mimctl <socket|-> <request...>
|
||||||
\\
|
\\
|
||||||
\\This early build exposes the canonical smoke path, compact local context,
|
\\With no path, mim opens the current directory. Existing files open in the
|
||||||
\\and a minimal local session-control client for trusted tools.
|
\\editor; missing paths start a new buffer; directories open the file browser.
|
||||||
|
\\This early build also exposes compact local context and mimctl for trusted tools.
|
||||||
\\
|
\\
|
||||||
;
|
;
|
||||||
|
|
||||||
const Command = union(enum) {
|
const Command = union(enum) {
|
||||||
help,
|
help,
|
||||||
version,
|
version,
|
||||||
smoke,
|
open: []const u8,
|
||||||
unknown: []const u8,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
fn parseArgs(args: []const []const u8) Command {
|
fn parseArgs(args: []const []const u8) Command {
|
||||||
if (args.len <= 1) return .smoke;
|
if (args.len <= 1) return .{ .open = "." };
|
||||||
|
|
||||||
const arg = args[1];
|
const arg = args[1];
|
||||||
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) return .help;
|
if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) return .help;
|
||||||
if (std.mem.eql(u8, arg, "--version")) return .version;
|
if (std.mem.eql(u8, arg, "--version")) return .version;
|
||||||
return .{ .unknown = arg };
|
return .{ .open = arg };
|
||||||
}
|
}
|
||||||
|
|
||||||
fn versionText() []const u8 {
|
fn versionText() []const u8 {
|
||||||
@@ -125,7 +128,18 @@ pub fn main(init: std.process.Init) !u8 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const command = if (first_arg) |arg|
|
var e2e_prelude_path: ?[]const u8 = null;
|
||||||
|
var open_arg = first_arg;
|
||||||
|
if (first_arg) |arg| {
|
||||||
|
if (std.mem.eql(u8, arg, "--e2e-prelude-file")) {
|
||||||
|
e2e_prelude_path = args.next() orelse {
|
||||||
|
try stderr.writeStreamingAll(init.io, "mim: --e2e-prelude-file requires a path\n");
|
||||||
|
return 64;
|
||||||
|
};
|
||||||
|
open_arg = args.next();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const command = if (open_arg) |arg|
|
||||||
parseArgs(&.{ "mim", arg })
|
parseArgs(&.{ "mim", arg })
|
||||||
else
|
else
|
||||||
parseArgs(&.{"mim"});
|
parseArgs(&.{"mim"});
|
||||||
@@ -133,18 +147,329 @@ pub fn main(init: std.process.Init) !u8 {
|
|||||||
switch (command) {
|
switch (command) {
|
||||||
.help => try stdout.writeStreamingAll(init.io, help_text),
|
.help => try stdout.writeStreamingAll(init.io, help_text),
|
||||||
.version => try stdout.writeStreamingAll(init.io, versionText()),
|
.version => try stdout.writeStreamingAll(init.io, versionText()),
|
||||||
.smoke => try stdout.writeStreamingAll(init.io, help_text),
|
.open => |path| try openLocalEditor(allocator, init.io, stdout, stderr, path, e2e_prelude_path),
|
||||||
.unknown => |arg| {
|
|
||||||
try stderr.writeStreamingAll(init.io, "mim: unknown argument '");
|
|
||||||
try stderr.writeStreamingAll(init.io, arg);
|
|
||||||
try stderr.writeStreamingAll(init.io, "'\nTry 'mim --help'.\n");
|
|
||||||
return 64;
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn openLocalEditor(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
io: std.Io,
|
||||||
|
stdout: std.Io.File,
|
||||||
|
stderr: std.Io.File,
|
||||||
|
path: []const u8,
|
||||||
|
e2e_prelude_path: ?[]const u8,
|
||||||
|
) !void {
|
||||||
|
var client = try tui.Client.init(allocator, .{ .width = 80, .height = 22 });
|
||||||
|
defer client.deinit();
|
||||||
|
|
||||||
|
const stat = std.Io.Dir.cwd().statFile(io, path, .{}) catch |err| switch (err) {
|
||||||
|
error.FileNotFound => null,
|
||||||
|
else => {
|
||||||
|
try stderr.writeStreamingAll(io, "mim: could not inspect path\n");
|
||||||
|
return err;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
if (stat) |metadata| {
|
||||||
|
switch (metadata.kind) {
|
||||||
|
.directory => try openDirectoryPreview(allocator, io, &client, path),
|
||||||
|
.file => {
|
||||||
|
if (std.fs.path.dirname(path)) |parent| seedDirectoryRepo(allocator, io, &client, parent) catch {};
|
||||||
|
const bytes = std.Io.Dir.cwd().readFileAlloc(io, path, allocator, .limited(diagnostics.max_file_bytes)) catch |err| switch (err) {
|
||||||
|
error.StreamTooLong => {
|
||||||
|
try client.handleTraceLine("open diagnostic:file_too_large");
|
||||||
|
return runLocalEditor(allocator, io, stdout, stderr, &client, path, false, e2e_prelude_path);
|
||||||
|
},
|
||||||
|
else => return err,
|
||||||
|
};
|
||||||
|
defer allocator.free(bytes);
|
||||||
|
const command = try std.fmt.allocPrint(allocator, "open {s}", .{bytes});
|
||||||
|
defer allocator.free(command);
|
||||||
|
try client.handleTraceLine(command);
|
||||||
|
},
|
||||||
|
else => try client.handleTraceLine("open diagnostic:unsupported_file"),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
try client.handleTraceLine("open ");
|
||||||
|
}
|
||||||
|
|
||||||
|
try runLocalEditor(allocator, io, stdout, stderr, &client, path, stat != null and stat.?.kind == .directory, e2e_prelude_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn seedDirectoryRepo(allocator: std.mem.Allocator, io: std.Io, client: *tui.Client, path: []const u8) !void {
|
||||||
|
var dir = try std.Io.Dir.cwd().openDir(io, path, .{ .iterate = true });
|
||||||
|
defer dir.close(io);
|
||||||
|
|
||||||
|
var iterator = dir.iterate();
|
||||||
|
var count: usize = 0;
|
||||||
|
while (try iterator.next(io)) |entry| {
|
||||||
|
if (count >= 80) break;
|
||||||
|
if (entry.kind != .file and entry.kind != .directory) continue;
|
||||||
|
const relative_path = try std.fmt.allocPrint(allocator, "{s}/{s}{s}", .{ path, entry.name, if (entry.kind == .directory) "/" else "" });
|
||||||
|
defer allocator.free(relative_path);
|
||||||
|
if (entry.kind == .file) {
|
||||||
|
const bytes = std.Io.Dir.cwd().readFileAlloc(io, relative_path, allocator, .limited(diagnostics.max_file_bytes)) catch |err| switch (err) {
|
||||||
|
error.StreamTooLong, error.AccessDenied, error.FileNotFound, error.NotDir => null,
|
||||||
|
else => null,
|
||||||
|
};
|
||||||
|
if (bytes) |content| {
|
||||||
|
defer allocator.free(content);
|
||||||
|
client.addRepoFileContent(relative_path, content) catch {};
|
||||||
|
} else {
|
||||||
|
client.addRepoFileContent(relative_path, "") catch {};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
client.addRepoFileContent(relative_path, "") catch {};
|
||||||
|
}
|
||||||
|
count += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn openDirectoryPreview(allocator: std.mem.Allocator, io: std.Io, client: *tui.Client, path: []const u8) !void {
|
||||||
|
try seedDirectoryRepo(allocator, io, client, path);
|
||||||
|
try client.handleTraceLine("file_picker");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn runLocalEditor(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
io: std.Io,
|
||||||
|
stdout: std.Io.File,
|
||||||
|
stderr: std.Io.File,
|
||||||
|
client: *tui.Client,
|
||||||
|
path: []const u8,
|
||||||
|
is_dir: bool,
|
||||||
|
e2e_prelude_path: ?[]const u8,
|
||||||
|
) !void {
|
||||||
|
const stdin_file = std.Io.File.stdin();
|
||||||
|
const interactive = stdin_file.isTty(io) catch false;
|
||||||
|
|
||||||
|
var last_saved = try client.snapshotBytesAlloc(allocator);
|
||||||
|
defer allocator.free(last_saved);
|
||||||
|
var last_client_saved: ?[]u8 = null;
|
||||||
|
defer if (last_client_saved) |bytes| allocator.free(bytes);
|
||||||
|
|
||||||
|
if (e2e_prelude_path) |prelude_path| {
|
||||||
|
const prelude = try std.Io.Dir.cwd().readFileAlloc(io, prelude_path, allocator, .limited(64 * 1024));
|
||||||
|
defer allocator.free(prelude);
|
||||||
|
var lines = std.mem.splitScalar(u8, prelude, '\n');
|
||||||
|
while (lines.next()) |line| if (line.len != 0) try client.handleTraceLine(line);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!interactive) {
|
||||||
|
try renderLocalFrame(allocator, io, stdout, client, path, is_dir, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (is_dir) try client.openFilePicker();
|
||||||
|
|
||||||
|
var raw_terminal = try RawTerminal.enable(stdin_file.handle);
|
||||||
|
defer raw_terminal.restore();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
try renderLocalFrame(allocator, io, stdout, client, path, is_dir, true);
|
||||||
|
const raw = try readEditorInputFdAlloc(allocator, stdin_file.handle);
|
||||||
|
defer if (raw) |bytes| allocator.free(bytes);
|
||||||
|
if (raw == null) return;
|
||||||
|
|
||||||
|
client.handleInput(raw.?) catch |err| {
|
||||||
|
if (err == tui.Error.ClientQuit) return;
|
||||||
|
try stderr.writeStreamingAll(io, "mim: input failed: ");
|
||||||
|
try stderr.writeStreamingAll(io, @errorName(err));
|
||||||
|
try stderr.writeStreamingAll(io, "\n");
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
const after = try client.snapshotBytesAlloc(allocator);
|
||||||
|
defer allocator.free(after);
|
||||||
|
var dirty = !std.mem.eql(u8, after, last_saved);
|
||||||
|
if (client.saved()) |saved_bytes| {
|
||||||
|
const new_save_request = last_client_saved == null or !std.mem.eql(u8, saved_bytes, last_client_saved.?);
|
||||||
|
if (new_save_request) {
|
||||||
|
if (is_dir and client.currentPath() == null) {
|
||||||
|
client.setStatusMessage("directory browser has nothing to save");
|
||||||
|
} else {
|
||||||
|
const save_path = try resolveLocalSavePath(allocator, path, client.currentPath());
|
||||||
|
defer allocator.free(save_path);
|
||||||
|
try persistLocalSave(allocator, io, save_path, saved_bytes, &last_saved, &last_client_saved);
|
||||||
|
dirty = false;
|
||||||
|
client.setStatusMessage("saved");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else |_| {}
|
||||||
|
|
||||||
|
if (client.requestedQuit()) {
|
||||||
|
if (dirty and !client.requestedDiscardQuit()) {
|
||||||
|
client.clearQuit();
|
||||||
|
client.setStatusMessage("dirty buffer: Space w saves, Space Q discards");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn resolveLocalSavePath(allocator: std.mem.Allocator, launch_path: []const u8, current_path: ?[]const u8) ![]u8 {
|
||||||
|
const selected = current_path orelse launch_path;
|
||||||
|
if (std.fs.path.isAbsolute(selected) or std.mem.indexOfScalar(u8, selected, '/') != null) return allocator.dupe(u8, selected);
|
||||||
|
const base = std.fs.path.dirname(launch_path) orelse ".";
|
||||||
|
return std.fs.path.join(allocator, &.{ base, selected });
|
||||||
|
}
|
||||||
|
|
||||||
|
fn persistLocalSave(
|
||||||
|
allocator: std.mem.Allocator,
|
||||||
|
io: std.Io,
|
||||||
|
path: []const u8,
|
||||||
|
bytes: []const u8,
|
||||||
|
last_saved: *[]u8,
|
||||||
|
last_client_saved: *?[]u8,
|
||||||
|
) !void {
|
||||||
|
try std.Io.Dir.cwd().writeFile(io, .{ .sub_path = path, .data = bytes });
|
||||||
|
const saved_copy = try allocator.dupe(u8, bytes);
|
||||||
|
errdefer allocator.free(saved_copy);
|
||||||
|
const client_copy = try allocator.dupe(u8, bytes);
|
||||||
|
allocator.free(last_saved.*);
|
||||||
|
last_saved.* = saved_copy;
|
||||||
|
if (last_client_saved.*) |old| allocator.free(old);
|
||||||
|
last_client_saved.* = client_copy;
|
||||||
|
}
|
||||||
|
|
||||||
|
const RawTerminal = struct {
|
||||||
|
fd: std.posix.fd_t,
|
||||||
|
original: std.posix.termios,
|
||||||
|
|
||||||
|
fn enable(fd: std.posix.fd_t) !RawTerminal {
|
||||||
|
const original = try std.posix.tcgetattr(fd);
|
||||||
|
var raw = original;
|
||||||
|
raw.iflag.BRKINT = false;
|
||||||
|
raw.iflag.ICRNL = false;
|
||||||
|
raw.iflag.INPCK = false;
|
||||||
|
raw.iflag.ISTRIP = false;
|
||||||
|
raw.iflag.IXON = false;
|
||||||
|
raw.oflag.OPOST = false;
|
||||||
|
raw.cflag.CSIZE = .CS8;
|
||||||
|
raw.lflag.ECHO = false;
|
||||||
|
raw.lflag.ICANON = false;
|
||||||
|
raw.lflag.IEXTEN = false;
|
||||||
|
raw.lflag.ISIG = false;
|
||||||
|
raw.cc[@intFromEnum(std.posix.V.MIN)] = 1;
|
||||||
|
raw.cc[@intFromEnum(std.posix.V.TIME)] = 0;
|
||||||
|
try std.posix.tcsetattr(fd, .FLUSH, raw);
|
||||||
|
return .{ .fd = fd, .original = original };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore(self: *RawTerminal) void {
|
||||||
|
std.posix.tcsetattr(self.fd, .FLUSH, self.original) catch {};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fn readEditorInputFdAlloc(allocator: std.mem.Allocator, fd: std.posix.fd_t) !?[]u8 {
|
||||||
|
var one: [1]u8 = undefined;
|
||||||
|
const n = try std.posix.read(fd, &one);
|
||||||
|
if (n == 0) return null;
|
||||||
|
var bytes = std.ArrayList(u8).empty;
|
||||||
|
errdefer bytes.deinit(allocator);
|
||||||
|
try bytes.append(allocator, one[0]);
|
||||||
|
if (one[0] != 0x1b) return try bytes.toOwnedSlice(allocator);
|
||||||
|
|
||||||
|
var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
|
||||||
|
const ready = std.posix.poll(&fds, 30) catch 0;
|
||||||
|
if (ready == 0 or (fds[0].revents & std.posix.POLL.IN) == 0) return try bytes.toOwnedSlice(allocator);
|
||||||
|
const second_n = try std.posix.read(fd, &one);
|
||||||
|
if (second_n == 0) return try bytes.toOwnedSlice(allocator);
|
||||||
|
try bytes.append(allocator, one[0]);
|
||||||
|
if (one[0] == '[') {
|
||||||
|
while (bytes.items.len < 8) {
|
||||||
|
const next_ready = std.posix.poll(&fds, 30) catch 0;
|
||||||
|
if (next_ready == 0 or (fds[0].revents & std.posix.POLL.IN) == 0) break;
|
||||||
|
const next_n = try std.posix.read(fd, &one);
|
||||||
|
if (next_n == 0) break;
|
||||||
|
try bytes.append(allocator, one[0]);
|
||||||
|
if (one[0] >= '@' and one[0] <= '~') break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return try bytes.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn readEditorInputAlloc(allocator: std.mem.Allocator, stdin: *std.Io.Reader, fd: std.posix.fd_t) !?[]u8 {
|
||||||
|
const first = stdin.takeByte() catch |err| switch (err) {
|
||||||
|
error.EndOfStream => return null,
|
||||||
|
else => return err,
|
||||||
|
};
|
||||||
|
var bytes = std.ArrayList(u8).empty;
|
||||||
|
errdefer bytes.deinit(allocator);
|
||||||
|
try bytes.append(allocator, first);
|
||||||
|
|
||||||
|
if (first == 0x1b) {
|
||||||
|
var fds = [_]std.posix.pollfd{.{ .fd = fd, .events = std.posix.POLL.IN, .revents = 0 }};
|
||||||
|
const ready: usize = if (fd < 0) 1 else std.posix.poll(&fds, 30) catch 0;
|
||||||
|
if (ready == 0 or (fd >= 0 and (fds[0].revents & std.posix.POLL.IN) == 0)) return try bytes.toOwnedSlice(allocator);
|
||||||
|
const second = stdin.takeByte() catch return try bytes.toOwnedSlice(allocator);
|
||||||
|
try bytes.append(allocator, second);
|
||||||
|
if (second == '[') {
|
||||||
|
const third_ready: usize = if (fd < 0) 1 else std.posix.poll(&fds, 30) catch 0;
|
||||||
|
if (third_ready == 0 or (fd >= 0 and (fds[0].revents & std.posix.POLL.IN) == 0)) return try bytes.toOwnedSlice(allocator);
|
||||||
|
const third = stdin.takeByte() catch return try bytes.toOwnedSlice(allocator);
|
||||||
|
try bytes.append(allocator, third);
|
||||||
|
}
|
||||||
|
return try bytes.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
const len = std.unicode.utf8ByteSequenceLength(first) catch 1;
|
||||||
|
var i: usize = 1;
|
||||||
|
while (i < len) : (i += 1) {
|
||||||
|
const next = stdin.takeByte() catch break;
|
||||||
|
try bytes.append(allocator, next);
|
||||||
|
}
|
||||||
|
return try bytes.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn renderLocalFrame(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File, client: *tui.Client, path: []const u8, is_dir: bool, interactive: bool) !void {
|
||||||
|
const frame = try client.render(allocator);
|
||||||
|
defer allocator.free(frame);
|
||||||
|
if (interactive) {
|
||||||
|
try stdout.writeStreamingAll(io, "\x1b[H\x1b[2J");
|
||||||
|
try writeTerminalText(allocator, io, stdout, frame);
|
||||||
|
try writeTerminalText(allocator, io, stdout, "\n\n");
|
||||||
|
try writeTerminalText(allocator, io, stdout, "mim opened: ");
|
||||||
|
try writeTerminalText(allocator, io, stdout, path);
|
||||||
|
try writeTerminalText(allocator, io, stdout, if (is_dir)
|
||||||
|
"\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n"
|
||||||
|
else
|
||||||
|
"\nEditor starts in normal mode. Press i to insert; Space commands work in normal mode; Space w saves; Space q quits when clean; Space Q discards dirty changes.\n");
|
||||||
|
} else {
|
||||||
|
try stdout.writeStreamingAll(io, frame);
|
||||||
|
try stdout.writeStreamingAll(io, "\n\n");
|
||||||
|
try stdout.writeStreamingAll(io, "mim opened: ");
|
||||||
|
try stdout.writeStreamingAll(io, path);
|
||||||
|
try stdout.writeStreamingAll(io, if (is_dir)
|
||||||
|
"\nDirectory browser. Space opens commands; o/Enter opens panel items where available; q quits when clean.\n"
|
||||||
|
else
|
||||||
|
"\nEditor starts in normal mode. Press i to insert; Space commands work in normal mode; Space w saves; Space q quits when clean; Space Q discards dirty changes.\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn writeTerminalText(allocator: std.mem.Allocator, io: std.Io, stdout: std.Io.File, bytes: []const u8) !void {
|
||||||
|
const converted = try terminalCrlfAlloc(allocator, bytes);
|
||||||
|
defer allocator.free(converted);
|
||||||
|
try stdout.writeStreamingAll(io, converted);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn terminalCrlfAlloc(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
|
||||||
|
var out = std.ArrayList(u8).empty;
|
||||||
|
errdefer out.deinit(allocator);
|
||||||
|
var start: usize = 0;
|
||||||
|
for (bytes, 0..) |byte, i| {
|
||||||
|
if (byte != '\n') continue;
|
||||||
|
if (start < i) try out.appendSlice(allocator, bytes[start..i]);
|
||||||
|
try out.appendSlice(allocator, "\r\n");
|
||||||
|
start = i + 1;
|
||||||
|
}
|
||||||
|
if (start < bytes.len) try out.appendSlice(allocator, bytes[start..]);
|
||||||
|
return out.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, stdout: std.Io.File) !void {
|
fn printLocalContext(allocator: std.mem.Allocator, io: std.Io, args: *std.process.Args.Iterator, stdout: std.Io.File) !void {
|
||||||
const task = args.next();
|
const task = args.next();
|
||||||
const intent = args.next();
|
const intent = args.next();
|
||||||
@@ -245,9 +570,9 @@ test {
|
|||||||
_ = tui;
|
_ = tui;
|
||||||
}
|
}
|
||||||
|
|
||||||
test "regular: help text names the binary and smoke boundary" {
|
test "regular: help text names the binary and open path" {
|
||||||
try std.testing.expect(std.mem.indexOf(u8, help_text, "mim - mobile-first terminal code editor") != null);
|
try std.testing.expect(std.mem.indexOf(u8, help_text, "mim - mobile-first terminal code editor") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, help_text, "canonical smoke path") != null);
|
try std.testing.expect(std.mem.indexOf(u8, help_text, "mim [path]") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, help_text, "mim context") != null);
|
try std.testing.expect(std.mem.indexOf(u8, help_text, "mim context") != null);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, help_text, "mim profile") != null);
|
try std.testing.expect(std.mem.indexOf(u8, help_text, "mim profile") != null);
|
||||||
}
|
}
|
||||||
@@ -264,20 +589,87 @@ test "regular: version output is stable enough for smoke checks" {
|
|||||||
try std.testing.expectEqualStrings("mim 0.1.0-dev\n", versionText());
|
try std.testing.expectEqualStrings("mim 0.1.0-dev\n", versionText());
|
||||||
}
|
}
|
||||||
|
|
||||||
test "adversarial: unknown arguments are rejected instead of ignored" {
|
test "regular: no args opens current directory" {
|
||||||
const argv = [_][]const u8{ "mim", "--definitely-not-supported" };
|
const argv = [_][]const u8{"mim"};
|
||||||
const parsed = parseArgs(&argv);
|
const parsed = parseArgs(&argv);
|
||||||
switch (parsed) {
|
switch (parsed) {
|
||||||
.unknown => |arg| try std.testing.expectEqualStrings("--definitely-not-supported", arg),
|
.open => |path| try std.testing.expectEqualStrings(".", path),
|
||||||
else => return error.ExpectedUnknownArgument,
|
else => return error.ExpectedOpenPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
test "adversarial: extra positional arguments are not treated as files yet" {
|
test "regular: positional path opens file directory or new buffer" {
|
||||||
const argv = [_][]const u8{ "mim", "some-file.zig" };
|
const argv = [_][]const u8{ "mim", "some-file.zig" };
|
||||||
const parsed = parseArgs(&argv);
|
const parsed = parseArgs(&argv);
|
||||||
switch (parsed) {
|
switch (parsed) {
|
||||||
.unknown => |arg| try std.testing.expectEqualStrings("some-file.zig", arg),
|
.open => |path| try std.testing.expectEqualStrings("some-file.zig", path),
|
||||||
else => return error.ExpectedUnknownArgument,
|
else => return error.ExpectedOpenPath,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: local editor input reader preserves text utf8 and arrows" {
|
||||||
|
var ascii = std.Io.Reader.fixed("a");
|
||||||
|
const ascii_event = (try readEditorInputAlloc(std.testing.allocator, &ascii, -1)).?;
|
||||||
|
defer std.testing.allocator.free(ascii_event);
|
||||||
|
try std.testing.expectEqualStrings("a", ascii_event);
|
||||||
|
|
||||||
|
var utf8 = std.Io.Reader.fixed("é");
|
||||||
|
const utf8_event = (try readEditorInputAlloc(std.testing.allocator, &utf8, -1)).?;
|
||||||
|
defer std.testing.allocator.free(utf8_event);
|
||||||
|
try std.testing.expectEqualStrings("é", utf8_event);
|
||||||
|
|
||||||
|
var arrow = std.Io.Reader.fixed("\x1b[D");
|
||||||
|
const arrow_event = (try readEditorInputAlloc(std.testing.allocator, &arrow, -1)).?;
|
||||||
|
defer std.testing.allocator.free(arrow_event);
|
||||||
|
try std.testing.expectEqualStrings("\x1b[D", arrow_event);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: local editor client exposes save and dirty quit guards" {
|
||||||
|
var client = try tui.Client.init(std.testing.allocator, .{ .width = 32, .height = 8 });
|
||||||
|
defer client.deinit();
|
||||||
|
|
||||||
|
try client.handleTraceLine("open ");
|
||||||
|
const initial = try client.snapshotBytesAlloc(std.testing.allocator);
|
||||||
|
defer std.testing.allocator.free(initial);
|
||||||
|
try std.testing.expectEqualStrings("", initial);
|
||||||
|
|
||||||
|
try client.handleInput("i");
|
||||||
|
try client.handleInput("h");
|
||||||
|
try client.handleInput("e");
|
||||||
|
try client.handleInput("l");
|
||||||
|
try client.handleInput("l");
|
||||||
|
try client.handleInput("o");
|
||||||
|
const edited = try client.snapshotBytesAlloc(std.testing.allocator);
|
||||||
|
defer std.testing.allocator.free(edited);
|
||||||
|
try std.testing.expectEqualStrings("hello", edited);
|
||||||
|
|
||||||
|
try client.handleInput(" ");
|
||||||
|
try client.handleInput("x");
|
||||||
|
const with_space = try client.snapshotBytesAlloc(std.testing.allocator);
|
||||||
|
defer std.testing.allocator.free(with_space);
|
||||||
|
try std.testing.expectEqualStrings("hello x", with_space);
|
||||||
|
try client.handleTraceLine("key escape");
|
||||||
|
try std.testing.expectEqualStrings("normal", client.modeName());
|
||||||
|
try client.handleInput(" ");
|
||||||
|
try client.handleInput("w");
|
||||||
|
try std.testing.expectEqualStrings("hello x", try client.saved());
|
||||||
|
|
||||||
|
try client.handleInput("i");
|
||||||
|
try client.handleInput("!");
|
||||||
|
try client.handleTraceLine("key escape");
|
||||||
|
try client.handleInput(" ");
|
||||||
|
try client.handleInput("q");
|
||||||
|
try std.testing.expect(client.requestedQuit());
|
||||||
|
client.clearQuit();
|
||||||
|
try std.testing.expect(!client.requestedQuit());
|
||||||
|
}
|
||||||
|
|
||||||
|
test "terminal debug helpers are included in main test root" {
|
||||||
|
_ = terminal_debug;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: raw terminal output converts newline to carriage-return newline" {
|
||||||
|
const converted = try terminalCrlfAlloc(std.testing.allocator, "a\nb\n");
|
||||||
|
defer std.testing.allocator.free(converted);
|
||||||
|
try std.testing.expectEqualStrings("a\r\nb\r\n", converted);
|
||||||
|
}
|
||||||
|
|||||||
+11
-22
@@ -67,51 +67,40 @@ fn containsForbiddenToken(line: []const u8) bool {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
test "regular: mobile trace performs code edit with leader save and quit" {
|
test "regular: mobile trace performs normal-mode edit with leader save and quit" {
|
||||||
const trace =
|
const trace =
|
||||||
\\open
|
\\open safe
|
||||||
\\type call
|
\\type r
|
||||||
|
\\type x
|
||||||
\\key space
|
\\key space
|
||||||
\\key p
|
\\key w
|
||||||
\\key p
|
|
||||||
\\type arg
|
|
||||||
\\key right
|
|
||||||
\\key space
|
|
||||||
\\key p
|
|
||||||
\\key s
|
|
||||||
\\key space
|
|
||||||
\\key s
|
|
||||||
\\key space
|
\\key space
|
||||||
\\key q
|
\\key q
|
||||||
\\
|
\\
|
||||||
;
|
;
|
||||||
try runMobileTask(std.testing.allocator, trace, "call(arg)/");
|
try runMobileTask(std.testing.allocator, trace, "xafe");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "regular: mobile trace edits UTF-8 and hard-to-reach braces" {
|
test "regular: mobile trace inserts hard-to-reach braces from symbol rail" {
|
||||||
const trace =
|
const trace =
|
||||||
\\open safe
|
\\open safe
|
||||||
\\type é
|
|
||||||
\\key backspace
|
|
||||||
\\key space
|
\\key space
|
||||||
\\key p
|
\\key p
|
||||||
\\key c
|
\\key c
|
||||||
\\type x
|
|
||||||
\\key right
|
|
||||||
\\key space
|
\\key space
|
||||||
\\key s
|
\\key w
|
||||||
\\key space
|
\\key space
|
||||||
\\key q
|
\\key q
|
||||||
\\
|
\\
|
||||||
;
|
;
|
||||||
try runMobileTask(std.testing.allocator, trace, "{x}safe");
|
try runMobileTask(std.testing.allocator, trace, "{}safe");
|
||||||
}
|
}
|
||||||
|
|
||||||
test "regular: mobile search entry is reachable and recoverable without desktop chords" {
|
test "regular: mobile search entry is reachable and recoverable without desktop chords" {
|
||||||
const trace =
|
const trace =
|
||||||
\\open abc
|
\\open abc
|
||||||
\\key space
|
\\key space
|
||||||
\\key /
|
\\key s
|
||||||
\\
|
\\
|
||||||
;
|
;
|
||||||
try assertMobileOnlyTrace(trace);
|
try assertMobileOnlyTrace(trace);
|
||||||
@@ -123,7 +112,7 @@ test "regular: mobile search entry is reachable and recoverable without desktop
|
|||||||
}
|
}
|
||||||
const frame = try client.render(std.testing.allocator);
|
const frame = try client.render(std.testing.allocator);
|
||||||
defer std.testing.allocator.free(frame);
|
defer std.testing.allocator.free(frame);
|
||||||
try std.testing.expect(std.mem.indexOf(u8, frame, "search is not built") != null);
|
try std.testing.expect(std.mem.indexOf(u8, frame, "search: f current file") != null);
|
||||||
try std.testing.expect(!client.quit);
|
try std.testing.expect(!client.quit);
|
||||||
try std.testing.expectError(tui.Error.NothingSaved, client.saved());
|
try std.testing.expectError(tui.Error.NothingSaved, client.saved());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -155,6 +155,14 @@ pub const Stack = struct {
|
|||||||
return list.selected.?;
|
return list.selected.?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn activeListItem(self: *const Stack) ![]const u8 {
|
||||||
|
const index = self.active_index orelse return Error.NoPanelOpen;
|
||||||
|
const panel = self.panels.items[index];
|
||||||
|
if (panel.kind != .list or panel.list == null) return Error.ActivePanelIsNotList;
|
||||||
|
const list = panel.list.?;
|
||||||
|
return self.visibleItemAt(list.cursor) orelse Error.EmptyList;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cancelList(self: *Stack) !void {
|
pub fn cancelList(self: *Stack) !void {
|
||||||
_ = try self.activeList();
|
_ = try self.activeList();
|
||||||
try self.closeActive();
|
try self.closeActive();
|
||||||
|
|||||||
@@ -91,7 +91,23 @@ fn parseToolLog(log_line: []const u8) ?struct { name: []const u8, command: []con
|
|||||||
fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, command: []const u8) ![]u8 {
|
fn commandResponse(allocator: std.mem.Allocator, session: *session_mod.Session, command: []const u8) ![]u8 {
|
||||||
if (std.mem.eql(u8, command, "move_left")) return dispatchAndRespond(allocator, session, .move_left);
|
if (std.mem.eql(u8, command, "move_left")) return dispatchAndRespond(allocator, session, .move_left);
|
||||||
if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right);
|
if (std.mem.eql(u8, command, "move_right")) return dispatchAndRespond(allocator, session, .move_right);
|
||||||
|
if (std.mem.eql(u8, command, "move_up")) return dispatchAndRespond(allocator, session, .move_up);
|
||||||
|
if (std.mem.eql(u8, command, "move_down")) return dispatchAndRespond(allocator, session, .move_down);
|
||||||
|
if (std.mem.eql(u8, command, "move_word_forward")) return dispatchAndRespond(allocator, session, .move_word_forward);
|
||||||
|
if (std.mem.eql(u8, command, "move_word_back")) return dispatchAndRespond(allocator, session, .move_word_back);
|
||||||
|
if (std.mem.eql(u8, command, "move_word_end")) return dispatchAndRespond(allocator, session, .move_word_end);
|
||||||
|
if (std.mem.eql(u8, command, "move_line_first_nonblank")) return dispatchAndRespond(allocator, session, .move_line_first_nonblank);
|
||||||
|
if (std.mem.eql(u8, command, "move_line_start")) return dispatchAndRespond(allocator, session, .move_line_start);
|
||||||
|
if (std.mem.eql(u8, command, "move_line_end")) return dispatchAndRespond(allocator, session, .move_line_end);
|
||||||
|
if (std.mem.eql(u8, command, "move_document_start")) return dispatchAndRespond(allocator, session, .move_document_start);
|
||||||
|
if (std.mem.eql(u8, command, "move_document_end")) return dispatchAndRespond(allocator, session, .move_document_end);
|
||||||
if (std.mem.eql(u8, command, "delete_backward")) return dispatchAndRespond(allocator, session, .delete_backward);
|
if (std.mem.eql(u8, command, "delete_backward")) return dispatchAndRespond(allocator, session, .delete_backward);
|
||||||
|
if (std.mem.eql(u8, command, "delete_forward")) return dispatchAndRespond(allocator, session, .delete_forward);
|
||||||
|
if (std.mem.eql(u8, command, "delete_line")) return dispatchAndRespond(allocator, session, .delete_line);
|
||||||
|
if (std.mem.eql(u8, command, "change_line")) return dispatchAndRespond(allocator, session, .change_line);
|
||||||
|
if (std.mem.eql(u8, command, "open_line_below")) return dispatchAndRespond(allocator, session, .open_line_below);
|
||||||
|
if (std.mem.eql(u8, command, "open_line_above")) return dispatchAndRespond(allocator, session, .open_line_above);
|
||||||
|
if (std.mem.startsWith(u8, command, "replace_char ")) return dispatchAndRespond(allocator, session, .{ .replace_char = command[13..] });
|
||||||
if (std.mem.startsWith(u8, command, "insert ")) return dispatchAndRespond(allocator, session, .{ .insert = command[7..] });
|
if (std.mem.startsWith(u8, command, "insert ")) return dispatchAndRespond(allocator, session, .{ .insert = command[7..] });
|
||||||
if (std.mem.startsWith(u8, command, "panel_open ")) return panelRespond(allocator, session, .{ .open = command[11..] });
|
if (std.mem.startsWith(u8, command, "panel_open ")) return panelRespond(allocator, session, .{ .open = command[11..] });
|
||||||
if (std.mem.startsWith(u8, command, "list_open ")) return listOpenRespond(allocator, session, command[10..]);
|
if (std.mem.startsWith(u8, command, "list_open ")) return listOpenRespond(allocator, session, command[10..]);
|
||||||
@@ -474,3 +490,19 @@ test "adversarial: protocol list errors are explicit" {
|
|||||||
try std.testing.expectEqualStrings("err no panel open\n", response);
|
try std.testing.expectEqualStrings("err no panel open\n", response);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: protocol exposes first-nonblank line motion" {
|
||||||
|
var session = session_mod.Session.init(std.testing.allocator);
|
||||||
|
defer session.deinit();
|
||||||
|
|
||||||
|
var response = try handleLine(std.testing.allocator, &session, "open top\n alpha\n");
|
||||||
|
std.testing.allocator.free(response);
|
||||||
|
response = try handleLine(std.testing.allocator, &session, "command move_down\n");
|
||||||
|
std.testing.allocator.free(response);
|
||||||
|
response = try handleLine(std.testing.allocator, &session, "command move_line_end\n");
|
||||||
|
std.testing.allocator.free(response);
|
||||||
|
response = try handleLine(std.testing.allocator, &session, "command move_line_first_nonblank\n");
|
||||||
|
defer std.testing.allocator.free(response);
|
||||||
|
|
||||||
|
try std.testing.expectEqualStrings("ok state cursor_byte=6 cursor_cell=2 bytes_len=11 panel_depth=0 active_panel=- panel_path=- panel_summary=-\n", response);
|
||||||
|
}
|
||||||
|
|||||||
+402
-8
@@ -26,9 +26,25 @@ pub const Pair = struct {
|
|||||||
pub const Command = union(enum) {
|
pub const Command = union(enum) {
|
||||||
move_left,
|
move_left,
|
||||||
move_right,
|
move_right,
|
||||||
|
move_up,
|
||||||
|
move_down,
|
||||||
|
move_word_forward,
|
||||||
|
move_word_back,
|
||||||
|
move_word_end,
|
||||||
|
move_line_first_nonblank,
|
||||||
insert: []const u8,
|
insert: []const u8,
|
||||||
insert_pair: Pair,
|
insert_pair: Pair,
|
||||||
delete_backward,
|
delete_backward,
|
||||||
|
delete_forward,
|
||||||
|
delete_line,
|
||||||
|
change_line,
|
||||||
|
open_line_below,
|
||||||
|
open_line_above,
|
||||||
|
replace_char: []const u8,
|
||||||
|
move_line_start,
|
||||||
|
move_line_end,
|
||||||
|
move_document_start,
|
||||||
|
move_document_end,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const Snapshot = struct {
|
pub const Snapshot = struct {
|
||||||
@@ -46,6 +62,7 @@ pub const Buffer = struct {
|
|||||||
bytes: std.ArrayList(u8),
|
bytes: std.ArrayList(u8),
|
||||||
cursor: Cursor = .{},
|
cursor: Cursor = .{},
|
||||||
selection: ?Selection = null,
|
selection: ?Selection = null,
|
||||||
|
preferred_vertical_cell: ?usize = null,
|
||||||
|
|
||||||
pub fn openFromBytes(allocator: std.mem.Allocator, fixture: []const u8) !Buffer {
|
pub fn openFromBytes(allocator: std.mem.Allocator, fixture: []const u8) !Buffer {
|
||||||
var bytes = std.ArrayList(u8).empty;
|
var bytes = std.ArrayList(u8).empty;
|
||||||
@@ -74,27 +91,103 @@ pub const Buffer = struct {
|
|||||||
switch (command) {
|
switch (command) {
|
||||||
.move_left => self.moveLeft(),
|
.move_left => self.moveLeft(),
|
||||||
.move_right => self.moveRight(),
|
.move_right => self.moveRight(),
|
||||||
|
.move_up => self.moveUp(),
|
||||||
|
.move_down => self.moveDown(),
|
||||||
|
.move_word_forward => self.moveWordForward(),
|
||||||
|
.move_word_back => self.moveWordBack(),
|
||||||
|
.move_word_end => self.moveWordEnd(),
|
||||||
|
.move_line_first_nonblank => self.moveLineFirstNonblank(),
|
||||||
.insert => |text| try self.insert(text),
|
.insert => |text| try self.insert(text),
|
||||||
.insert_pair => |pair| try self.insertPair(pair),
|
.insert_pair => |pair| try self.insertPair(pair),
|
||||||
.delete_backward => self.deleteBackward(),
|
.delete_backward => self.deleteBackward(),
|
||||||
|
.delete_forward => self.deleteForward(),
|
||||||
|
.delete_line => self.deleteLine(),
|
||||||
|
.change_line => try self.changeLine(),
|
||||||
|
.open_line_below => try self.openLineBelow(),
|
||||||
|
.open_line_above => try self.openLineAbove(),
|
||||||
|
.replace_char => |text| try self.replaceChar(text),
|
||||||
|
.move_line_start => self.moveLineStart(),
|
||||||
|
.move_line_end => self.moveLineEnd(),
|
||||||
|
.move_document_start => self.moveDocumentStart(),
|
||||||
|
.move_document_end => self.moveDocumentEnd(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn moveLeft(self: *Buffer) void {
|
pub fn moveLeft(self: *Buffer) void {
|
||||||
self.cursor.byte = previousBoundary(self.bytes.items, self.cursor.byte);
|
self.cursor.byte = previousBoundary(self.bytes.items, self.cursor.byte);
|
||||||
self.refreshCell();
|
self.refreshCellResetPreferred();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn moveRight(self: *Buffer) void {
|
pub fn moveRight(self: *Buffer) void {
|
||||||
self.cursor.byte = nextBoundary(self.bytes.items, self.cursor.byte);
|
self.cursor.byte = nextBoundary(self.bytes.items, self.cursor.byte);
|
||||||
self.refreshCell();
|
self.refreshCellResetPreferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveUp(self: *Buffer) void {
|
||||||
|
const current = self.currentLineRange(false);
|
||||||
|
const target_cell = self.preferred_vertical_cell orelse self.cursor.cell;
|
||||||
|
if (current.start == 0) {
|
||||||
|
self.preferred_vertical_cell = target_cell;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const previous_end = current.start - 1;
|
||||||
|
var previous_start = previous_end;
|
||||||
|
while (previous_start > 0 and self.bytes.items[previous_start - 1] != '\n') previous_start -= 1;
|
||||||
|
self.cursor.byte = byteForCell(self.bytes.items, previous_start, previous_end, target_cell);
|
||||||
|
self.refreshCellPreservePreferred(target_cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveDown(self: *Buffer) void {
|
||||||
|
const current = self.currentLineRange(false);
|
||||||
|
const target_cell = self.preferred_vertical_cell orelse self.cursor.cell;
|
||||||
|
if (current.end >= self.bytes.items.len) {
|
||||||
|
self.preferred_vertical_cell = target_cell;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next_start = current.end + 1;
|
||||||
|
if (next_start > self.bytes.items.len) {
|
||||||
|
self.preferred_vertical_cell = target_cell;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var next_end = next_start;
|
||||||
|
while (next_end < self.bytes.items.len and self.bytes.items[next_end] != '\n') next_end += 1;
|
||||||
|
self.cursor.byte = byteForCell(self.bytes.items, next_start, next_end, target_cell);
|
||||||
|
self.refreshCellPreservePreferred(target_cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveWordForward(self: *Buffer) void {
|
||||||
|
var at = self.cursor.byte;
|
||||||
|
while (at < self.bytes.items.len and !isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at);
|
||||||
|
while (at < self.bytes.items.len and isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at);
|
||||||
|
self.cursor.byte = at;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveWordBack(self: *Buffer) void {
|
||||||
|
var at = self.cursor.byte;
|
||||||
|
while (at > 0 and isWordSeparator(self.bytes.items[previousBoundary(self.bytes.items, at)])) at = previousBoundary(self.bytes.items, at);
|
||||||
|
while (at > 0 and !isWordSeparator(self.bytes.items[previousBoundary(self.bytes.items, at)])) at = previousBoundary(self.bytes.items, at);
|
||||||
|
self.cursor.byte = at;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveWordEnd(self: *Buffer) void {
|
||||||
|
var at = self.cursor.byte;
|
||||||
|
while (at < self.bytes.items.len and isWordSeparator(self.bytes.items[at])) at = nextBoundary(self.bytes.items, at);
|
||||||
|
while (at < self.bytes.items.len) {
|
||||||
|
const next = nextBoundary(self.bytes.items, at);
|
||||||
|
if (next >= self.bytes.items.len or isWordSeparator(self.bytes.items[next])) break;
|
||||||
|
at = next;
|
||||||
|
}
|
||||||
|
self.cursor.byte = at;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn insert(self: *Buffer, text: []const u8) !void {
|
pub fn insert(self: *Buffer, text: []const u8) !void {
|
||||||
if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8Insertion;
|
if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8Insertion;
|
||||||
try self.bytes.insertSlice(self.allocator, self.cursor.byte, text);
|
try self.bytes.insertSlice(self.allocator, self.cursor.byte, text);
|
||||||
self.cursor.byte += text.len;
|
self.cursor.byte += text.len;
|
||||||
self.refreshCell();
|
self.refreshCellResetPreferred();
|
||||||
self.selection = null;
|
self.selection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +197,7 @@ pub const Buffer = struct {
|
|||||||
defer self.allocator.free(combined);
|
defer self.allocator.free(combined);
|
||||||
try self.bytes.insertSlice(self.allocator, self.cursor.byte, combined);
|
try self.bytes.insertSlice(self.allocator, self.cursor.byte, combined);
|
||||||
self.cursor.byte += pair.open.len;
|
self.cursor.byte += pair.open.len;
|
||||||
self.refreshCell();
|
self.refreshCellResetPreferred();
|
||||||
self.selection = null;
|
self.selection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,12 +206,119 @@ pub const Buffer = struct {
|
|||||||
const start = previousBoundary(self.bytes.items, self.cursor.byte);
|
const start = previousBoundary(self.bytes.items, self.cursor.byte);
|
||||||
self.bytes.replaceRangeAssumeCapacity(start, self.cursor.byte - start, "");
|
self.bytes.replaceRangeAssumeCapacity(start, self.cursor.byte - start, "");
|
||||||
self.cursor.byte = start;
|
self.cursor.byte = start;
|
||||||
self.refreshCell();
|
self.refreshCellResetPreferred();
|
||||||
self.selection = null;
|
self.selection = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refreshCell(self: *Buffer) void {
|
pub fn deleteForward(self: *Buffer) void {
|
||||||
self.cursor.cell = cellWidth(self.bytes.items[0..self.cursor.byte]);
|
if (self.cursor.byte >= self.bytes.items.len) return;
|
||||||
|
const end = nextBoundary(self.bytes.items, self.cursor.byte);
|
||||||
|
self.bytes.replaceRangeAssumeCapacity(self.cursor.byte, end - self.cursor.byte, "");
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
self.selection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replaceChar(self: *Buffer, text: []const u8) !void {
|
||||||
|
if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8Insertion;
|
||||||
|
if (self.cursor.byte < self.bytes.items.len) self.deleteForward();
|
||||||
|
try self.insert(text);
|
||||||
|
self.moveLeft();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn deleteLine(self: *Buffer) void {
|
||||||
|
const range = self.currentLineRange(true);
|
||||||
|
self.bytes.replaceRangeAssumeCapacity(range.start, range.end - range.start, "");
|
||||||
|
self.cursor.byte = @min(range.start, self.bytes.items.len);
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
self.selection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn changeLine(self: *Buffer) !void {
|
||||||
|
const range = self.currentLineRange(false);
|
||||||
|
self.bytes.replaceRangeAssumeCapacity(range.start, range.end - range.start, "");
|
||||||
|
self.cursor.byte = @min(range.start, self.bytes.items.len);
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
self.selection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn openLineBelow(self: *Buffer) !void {
|
||||||
|
const range = self.currentLineRange(false);
|
||||||
|
const has_line_break = range.end < self.bytes.items.len and self.bytes.items[range.end] == '\n';
|
||||||
|
const at = if (has_line_break) range.end + 1 else self.bytes.items.len;
|
||||||
|
try self.bytes.insertSlice(self.allocator, at, "\n");
|
||||||
|
self.cursor.byte = if (has_line_break) at else at + 1;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
self.selection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn openLineAbove(self: *Buffer) !void {
|
||||||
|
const range = self.currentLineRange(false);
|
||||||
|
try self.bytes.insertSlice(self.allocator, range.start, "\n");
|
||||||
|
self.cursor.byte = range.start;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
self.selection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveLineStart(self: *Buffer) void {
|
||||||
|
self.cursor.byte = self.currentLineRange(false).start;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveLineEnd(self: *Buffer) void {
|
||||||
|
self.cursor.byte = self.currentLineRange(false).end;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveLineFirstNonblank(self: *Buffer) void {
|
||||||
|
const range = self.currentLineRange(false);
|
||||||
|
var at = range.start;
|
||||||
|
while (at < range.end and (self.bytes.items[at] == ' ' or self.bytes.items[at] == '\t')) : (at += 1) {}
|
||||||
|
self.cursor.byte = at;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveDocumentStart(self: *Buffer) void {
|
||||||
|
self.cursor.byte = 0;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn moveDocumentEnd(self: *Buffer) void {
|
||||||
|
if (self.bytes.items.len == 0) {
|
||||||
|
self.cursor.byte = 0;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var at = self.bytes.items.len;
|
||||||
|
if (at > 0 and self.bytes.items[at - 1] == '\n') at -= 1;
|
||||||
|
while (at > 0 and self.bytes.items[at - 1] != '\n') at -= 1;
|
||||||
|
self.cursor.byte = at;
|
||||||
|
self.refreshCellResetPreferred();
|
||||||
|
}
|
||||||
|
|
||||||
|
const LineRange = struct { start: usize, end: usize };
|
||||||
|
|
||||||
|
fn currentLineRange(self: *const Buffer, include_newline: bool) LineRange {
|
||||||
|
var start = self.cursor.byte;
|
||||||
|
while (start > 0 and self.bytes.items[start - 1] != '\n') start -= 1;
|
||||||
|
var end = self.cursor.byte;
|
||||||
|
while (end < self.bytes.items.len and self.bytes.items[end] != '\n') end += 1;
|
||||||
|
if (include_newline and end < self.bytes.items.len and self.bytes.items[end] == '\n') end += 1;
|
||||||
|
return .{ .start = start, .end = end };
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refreshCellResetPreferred(self: *Buffer) void {
|
||||||
|
self.refreshCellLineLocal();
|
||||||
|
self.preferred_vertical_cell = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refreshCellPreservePreferred(self: *Buffer, target_cell: usize) void {
|
||||||
|
self.refreshCellLineLocal();
|
||||||
|
self.preferred_vertical_cell = target_cell;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refreshCellLineLocal(self: *Buffer) void {
|
||||||
|
const line = self.currentLineRange(false);
|
||||||
|
self.cursor.cell = cellWidth(self.bytes.items[line.start..self.cursor.byte]);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -145,7 +345,7 @@ pub const Session = struct {
|
|||||||
if (self.buffer) |*buffer| buffer.deinit();
|
if (self.buffer) |*buffer| buffer.deinit();
|
||||||
var buffer = try Buffer.openFromBytes(self.allocator, fixture);
|
var buffer = try Buffer.openFromBytes(self.allocator, fixture);
|
||||||
buffer.cursor.byte = boundaryAtOrBefore(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len));
|
buffer.cursor.byte = boundaryAtOrBefore(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len));
|
||||||
buffer.refreshCell();
|
buffer.refreshCellResetPreferred();
|
||||||
self.buffer = buffer;
|
self.buffer = buffer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -190,6 +390,10 @@ pub const Session = struct {
|
|||||||
return self.panels.selectList();
|
return self.panels.selectList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn activeListItem(self: *const Session) ![]const u8 {
|
||||||
|
return self.panels.activeListItem();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn cancelListPanel(self: *Session) !void {
|
pub fn cancelListPanel(self: *Session) !void {
|
||||||
try self.panels.cancelList();
|
try self.panels.cancelList();
|
||||||
}
|
}
|
||||||
@@ -225,8 +429,63 @@ pub const Session = struct {
|
|||||||
.active_panel_title = panel_state.active_title,
|
.active_panel_title = panel_state.active_title,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn moveToByte(self: *Session, byte: usize) !void {
|
||||||
|
if (self.buffer) |*buffer| {
|
||||||
|
if (byte > buffer.bytes.items.len) return error.SelectionOutOfBounds;
|
||||||
|
buffer.cursor.byte = byte;
|
||||||
|
buffer.refreshCellResetPreferred();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return error.NoActiveBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selectRange(self: *Session, start: usize, end: usize) !void {
|
||||||
|
if (self.buffer) |*buffer| {
|
||||||
|
if (start > end or end > buffer.bytes.items.len) return error.SelectionOutOfBounds;
|
||||||
|
buffer.selection = .{ .anchor = start, .cursor = end };
|
||||||
|
buffer.cursor.byte = end;
|
||||||
|
buffer.refreshCellResetPreferred();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return error.NoActiveBuffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clearSelection(self: *Session) void {
|
||||||
|
if (self.buffer) |*buffer| buffer.selection = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn replaceRange(self: *Session, start: usize, end: usize, bytes: []const u8) !void {
|
||||||
|
if (!std.unicode.utf8ValidateSlice(bytes)) return error.InvalidUtf8Insertion;
|
||||||
|
if (self.buffer) |*buffer| {
|
||||||
|
if (start > end or end > buffer.bytes.items.len) return error.SelectionOutOfBounds;
|
||||||
|
try buffer.bytes.replaceRange(buffer.allocator, start, end - start, bytes);
|
||||||
|
buffer.cursor.byte = start + bytes.len;
|
||||||
|
buffer.refreshCellResetPreferred();
|
||||||
|
buffer.selection = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
return error.NoActiveBuffer;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
fn isWordSeparator(byte: u8) bool {
|
||||||
|
return std.ascii.isWhitespace(byte) or std.mem.indexOfScalar(u8, "(){}[]<>.,;:+-*/=\"'`", byte) != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn byteForCell(bytes: []const u8, start: usize, end: usize, target_cell: usize) usize {
|
||||||
|
var at = start;
|
||||||
|
var best = start;
|
||||||
|
while (at < end) {
|
||||||
|
const rel = cellWidth(bytes[start..at]);
|
||||||
|
if (rel > target_cell) break;
|
||||||
|
best = at;
|
||||||
|
at = nextBoundary(bytes, at);
|
||||||
|
}
|
||||||
|
if (cellWidth(bytes[start..@min(at, end)]) <= target_cell) return @min(at, end);
|
||||||
|
return best;
|
||||||
|
}
|
||||||
|
|
||||||
pub fn boundaryAtOrBefore(bytes: []const u8, cursor: usize) usize {
|
pub fn boundaryAtOrBefore(bytes: []const u8, cursor: usize) usize {
|
||||||
var i = @min(cursor, bytes.len);
|
var i = @min(cursor, bytes.len);
|
||||||
if (i == bytes.len) return i;
|
if (i == bytes.len) return i;
|
||||||
@@ -441,3 +700,138 @@ test "adversarial: session panel operations fail clearly on empty or invalid sta
|
|||||||
const snap = try session.snapshot();
|
const snap = try session.snapshot();
|
||||||
try std.testing.expectEqual(@as(usize, 0), snap.panel_depth);
|
try std.testing.expectEqual(@as(usize, 0), snap.panel_depth);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: buffer moves by line word and line edges" {
|
||||||
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "one\nab cd\nxy");
|
||||||
|
defer buffer.deinit();
|
||||||
|
|
||||||
|
buffer.moveDown();
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte);
|
||||||
|
buffer.moveRight();
|
||||||
|
buffer.moveRight();
|
||||||
|
buffer.moveDown();
|
||||||
|
try std.testing.expectEqual(@as(usize, 12), buffer.cursor.byte);
|
||||||
|
buffer.moveUp();
|
||||||
|
try std.testing.expectEqual(@as(usize, 6), buffer.cursor.byte);
|
||||||
|
buffer.moveLineStart();
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte);
|
||||||
|
buffer.moveWordForward();
|
||||||
|
try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte);
|
||||||
|
buffer.moveWordEnd();
|
||||||
|
try std.testing.expectEqual(@as(usize, 8), buffer.cursor.byte);
|
||||||
|
buffer.moveWordBack();
|
||||||
|
try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte);
|
||||||
|
buffer.moveLineEnd();
|
||||||
|
try std.testing.expectEqual(@as(usize, 9), buffer.cursor.byte);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: buffer line operations replace delete and open around utf8" {
|
||||||
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "éx\nsecond");
|
||||||
|
defer buffer.deinit();
|
||||||
|
|
||||||
|
try buffer.replaceChar("A");
|
||||||
|
var snap = buffer.snapshot();
|
||||||
|
try std.testing.expectEqualStrings("Ax\nsecond", snap.bytes);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte);
|
||||||
|
|
||||||
|
try buffer.openLineBelow();
|
||||||
|
try buffer.insert("below");
|
||||||
|
snap = buffer.snapshot();
|
||||||
|
try std.testing.expectEqualStrings("Ax\nbelow\nsecond", snap.bytes);
|
||||||
|
|
||||||
|
buffer.moveLineStart();
|
||||||
|
try buffer.changeLine();
|
||||||
|
try buffer.insert("changed");
|
||||||
|
snap = buffer.snapshot();
|
||||||
|
try std.testing.expectEqualStrings("Ax\nchanged\nsecond", snap.bytes);
|
||||||
|
|
||||||
|
buffer.deleteLine();
|
||||||
|
snap = buffer.snapshot();
|
||||||
|
try std.testing.expectEqualStrings("Ax\nsecond", snap.bytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: vertical motion preserves preferred column across short lines" {
|
||||||
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "abcdef\nxy\n123456\n");
|
||||||
|
defer buffer.deinit();
|
||||||
|
|
||||||
|
buffer.moveLineEnd();
|
||||||
|
try std.testing.expectEqual(@as(usize, 6), buffer.cursor.cell);
|
||||||
|
|
||||||
|
buffer.moveDown();
|
||||||
|
try std.testing.expectEqual(@as(usize, 9), buffer.cursor.byte);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), buffer.cursor.cell);
|
||||||
|
|
||||||
|
buffer.moveDown();
|
||||||
|
try std.testing.expectEqual(@as(usize, 16), buffer.cursor.byte);
|
||||||
|
try std.testing.expectEqual(@as(usize, 6), buffer.cursor.cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: horizontal motion resets vertical preferred column" {
|
||||||
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "abcdef\nxy\n123456\n");
|
||||||
|
defer buffer.deinit();
|
||||||
|
|
||||||
|
buffer.moveLineEnd();
|
||||||
|
buffer.moveDown();
|
||||||
|
buffer.moveLeft();
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), buffer.cursor.cell);
|
||||||
|
|
||||||
|
buffer.moveDown();
|
||||||
|
try std.testing.expectEqual(@as(usize, 11), buffer.cursor.byte);
|
||||||
|
try std.testing.expectEqual(@as(usize, 1), buffer.cursor.cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: line object selection includes the newline byte" {
|
||||||
|
var session = Session.init(std.testing.allocator);
|
||||||
|
defer session.deinit();
|
||||||
|
|
||||||
|
try session.openFixture("abc\ndef\n");
|
||||||
|
try session.selectRange(0, 4);
|
||||||
|
const snap = try session.snapshot();
|
||||||
|
try std.testing.expectEqualStrings("abc\n", snap.bytes[snap.selection.?.anchor..snap.selection.?.cursor]);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: document motions jump to top and bottom line" {
|
||||||
|
var session = Session.init(std.testing.allocator);
|
||||||
|
defer session.deinit();
|
||||||
|
|
||||||
|
try session.openFixtureAt("one\ntwo\nthree\n", 4);
|
||||||
|
try session.dispatch(.move_document_end);
|
||||||
|
var snap = try session.snapshot();
|
||||||
|
try std.testing.expectEqual(@as(usize, 8), snap.cursor_byte);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), snap.cursor_cell);
|
||||||
|
|
||||||
|
try session.dispatch(.move_document_start);
|
||||||
|
snap = try session.snapshot();
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), snap.cursor_byte);
|
||||||
|
try std.testing.expectEqual(@as(usize, 0), snap.cursor_cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: line first-nonblank is distinct from absolute line start" {
|
||||||
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "top\n \talpha\nend");
|
||||||
|
defer buffer.deinit();
|
||||||
|
|
||||||
|
buffer.moveDown();
|
||||||
|
buffer.moveLineEnd();
|
||||||
|
try std.testing.expectEqual(@as(usize, 12), buffer.cursor.byte);
|
||||||
|
|
||||||
|
buffer.moveLineStart();
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte);
|
||||||
|
|
||||||
|
buffer.moveLineEnd();
|
||||||
|
buffer.moveLineFirstNonblank();
|
||||||
|
try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte);
|
||||||
|
try std.testing.expectEqual(@as(usize, 2), buffer.cursor.cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "adversarial: line first-nonblank on blank or whitespace-only line goes to line end" {
|
||||||
|
var buffer = try Buffer.openFromBytes(std.testing.allocator, "a\n \n\t\tb");
|
||||||
|
defer buffer.deinit();
|
||||||
|
|
||||||
|
buffer.moveDown();
|
||||||
|
buffer.moveLineFirstNonblank();
|
||||||
|
try std.testing.expectEqual(@as(usize, 4), buffer.cursor.byte);
|
||||||
|
|
||||||
|
buffer.moveDown();
|
||||||
|
buffer.moveLineFirstNonblank();
|
||||||
|
try std.testing.expectEqual(@as(usize, 7), buffer.cursor.byte);
|
||||||
|
}
|
||||||
|
|||||||
+26
-2
@@ -12,6 +12,12 @@ pub const Symbol = enum {
|
|||||||
brackets,
|
brackets,
|
||||||
braces,
|
braces,
|
||||||
slash,
|
slash,
|
||||||
|
line_comment,
|
||||||
|
star,
|
||||||
|
ampersand,
|
||||||
|
hash,
|
||||||
|
dollar,
|
||||||
|
at,
|
||||||
pipe,
|
pipe,
|
||||||
double_quote,
|
double_quote,
|
||||||
single_quote,
|
single_quote,
|
||||||
@@ -31,6 +37,12 @@ pub const entries = [_]Entry{
|
|||||||
.{ .key = "b", .symbol = .brackets, .label = "[]", .protocol_command = "command pair brackets" },
|
.{ .key = "b", .symbol = .brackets, .label = "[]", .protocol_command = "command pair brackets" },
|
||||||
.{ .key = "c", .symbol = .braces, .label = "{}", .protocol_command = "command pair braces" },
|
.{ .key = "c", .symbol = .braces, .label = "{}", .protocol_command = "command pair braces" },
|
||||||
.{ .key = "s", .symbol = .slash, .label = "/", .protocol_command = "command insert /" },
|
.{ .key = "s", .symbol = .slash, .label = "/", .protocol_command = "command insert /" },
|
||||||
|
.{ .key = "f", .symbol = .line_comment, .label = "//", .protocol_command = "command insert //" },
|
||||||
|
.{ .key = "x", .symbol = .star, .label = "*", .protocol_command = "command insert *" },
|
||||||
|
.{ .key = "a", .symbol = .ampersand, .label = "&", .protocol_command = "command insert &" },
|
||||||
|
.{ .key = "h", .symbol = .hash, .label = "#", .protocol_command = "command insert #" },
|
||||||
|
.{ .key = "d", .symbol = .dollar, .label = "$", .protocol_command = "command insert $" },
|
||||||
|
.{ .key = "r", .symbol = .at, .label = "@", .protocol_command = "command insert @" },
|
||||||
.{ .key = "v", .symbol = .pipe, .label = "|", .protocol_command = "command insert |" },
|
.{ .key = "v", .symbol = .pipe, .label = "|", .protocol_command = "command insert |" },
|
||||||
.{ .key = "q", .symbol = .double_quote, .label = "\"\"", .protocol_command = "command pair double_quote" },
|
.{ .key = "q", .symbol = .double_quote, .label = "\"\"", .protocol_command = "command pair double_quote" },
|
||||||
.{ .key = "e", .symbol = .single_quote, .label = "''", .protocol_command = "command pair single_quote" },
|
.{ .key = "e", .symbol = .single_quote, .label = "''", .protocol_command = "command pair single_quote" },
|
||||||
@@ -38,7 +50,7 @@ pub const entries = [_]Entry{
|
|||||||
.{ .key = "u", .symbol = .underscore, .label = "_", .protocol_command = "command insert _" },
|
.{ .key = "u", .symbol = .underscore, .label = "_", .protocol_command = "command insert _" },
|
||||||
};
|
};
|
||||||
|
|
||||||
pub const rail_status = "symbols: p () b [] c {} s / v | q \"\" e '' t `` u _";
|
pub const rail_status = "symbols: p () b [] c {} s / f // x * a & h # d $ r @ v | q \"\" e '' t `` u _";
|
||||||
|
|
||||||
pub fn lookup(key: []const u8) ?Entry {
|
pub fn lookup(key: []const u8) ?Entry {
|
||||||
for (entries) |entry| {
|
for (entries) |entry| {
|
||||||
@@ -59,6 +71,12 @@ test "regular: symbol rail maps mobile-friendly letters to coding punctuation" {
|
|||||||
try std.testing.expectEqual(Symbol.brackets, lookup("b").?.symbol);
|
try std.testing.expectEqual(Symbol.brackets, lookup("b").?.symbol);
|
||||||
try std.testing.expectEqual(Symbol.braces, lookup("c").?.symbol);
|
try std.testing.expectEqual(Symbol.braces, lookup("c").?.symbol);
|
||||||
try std.testing.expectEqual(Symbol.slash, lookup("s").?.symbol);
|
try std.testing.expectEqual(Symbol.slash, lookup("s").?.symbol);
|
||||||
|
try std.testing.expectEqual(Symbol.line_comment, lookup("f").?.symbol);
|
||||||
|
try std.testing.expectEqual(Symbol.star, lookup("x").?.symbol);
|
||||||
|
try std.testing.expectEqual(Symbol.ampersand, lookup("a").?.symbol);
|
||||||
|
try std.testing.expectEqual(Symbol.hash, lookup("h").?.symbol);
|
||||||
|
try std.testing.expectEqual(Symbol.dollar, lookup("d").?.symbol);
|
||||||
|
try std.testing.expectEqual(Symbol.at, lookup("r").?.symbol);
|
||||||
try std.testing.expectEqual(Symbol.pipe, lookup("v").?.symbol);
|
try std.testing.expectEqual(Symbol.pipe, lookup("v").?.symbol);
|
||||||
try std.testing.expectEqual(Symbol.double_quote, lookup("q").?.symbol);
|
try std.testing.expectEqual(Symbol.double_quote, lookup("q").?.symbol);
|
||||||
try std.testing.expectEqual(Symbol.single_quote, lookup("e").?.symbol);
|
try std.testing.expectEqual(Symbol.single_quote, lookup("e").?.symbol);
|
||||||
@@ -71,6 +89,12 @@ test "regular: symbol rail protocol commands stay tiny and explicit" {
|
|||||||
try std.testing.expectEqualStrings("command pair brackets", protocolCommand(.brackets));
|
try std.testing.expectEqualStrings("command pair brackets", protocolCommand(.brackets));
|
||||||
try std.testing.expectEqualStrings("command pair braces", protocolCommand(.braces));
|
try std.testing.expectEqualStrings("command pair braces", protocolCommand(.braces));
|
||||||
try std.testing.expectEqualStrings("command insert /", protocolCommand(.slash));
|
try std.testing.expectEqualStrings("command insert /", protocolCommand(.slash));
|
||||||
|
try std.testing.expectEqualStrings("command insert //", protocolCommand(.line_comment));
|
||||||
|
try std.testing.expectEqualStrings("command insert *", protocolCommand(.star));
|
||||||
|
try std.testing.expectEqualStrings("command insert &", protocolCommand(.ampersand));
|
||||||
|
try std.testing.expectEqualStrings("command insert #", protocolCommand(.hash));
|
||||||
|
try std.testing.expectEqualStrings("command insert $", protocolCommand(.dollar));
|
||||||
|
try std.testing.expectEqualStrings("command insert @", protocolCommand(.at));
|
||||||
try std.testing.expectEqualStrings("command insert |", protocolCommand(.pipe));
|
try std.testing.expectEqualStrings("command insert |", protocolCommand(.pipe));
|
||||||
try std.testing.expectEqualStrings("command pair double_quote", protocolCommand(.double_quote));
|
try std.testing.expectEqualStrings("command pair double_quote", protocolCommand(.double_quote));
|
||||||
try std.testing.expectEqualStrings("command pair single_quote", protocolCommand(.single_quote));
|
try std.testing.expectEqualStrings("command pair single_quote", protocolCommand(.single_quote));
|
||||||
@@ -79,7 +103,7 @@ test "regular: symbol rail protocol commands stay tiny and explicit" {
|
|||||||
}
|
}
|
||||||
|
|
||||||
test "adversarial: unknown symbol key does not map to a symbol" {
|
test "adversarial: unknown symbol key does not map to a symbol" {
|
||||||
try std.testing.expectEqual(@as(?Entry, null), lookup("x"));
|
try std.testing.expectEqual(@as(?Entry, null), lookup("z"));
|
||||||
try std.testing.expectEqual(@as(?Entry, null), lookup("/"));
|
try std.testing.expectEqual(@as(?Entry, null), lookup("/"));
|
||||||
try std.testing.expectEqual(@as(?Entry, null), lookup(""));
|
try std.testing.expectEqual(@as(?Entry, null), lookup(""));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
/// Materialize terminal control characters as visible glyphs for failure output.
|
||||||
|
/// This is test/debug support only: product rendering still writes real CR/LF/ESC.
|
||||||
|
pub fn visibleControlsAlloc(allocator: std.mem.Allocator, bytes: []const u8) ![]u8 {
|
||||||
|
var out = std.ArrayList(u8).empty;
|
||||||
|
errdefer out.deinit(allocator);
|
||||||
|
for (bytes) |byte| switch (byte) {
|
||||||
|
'\r' => try out.appendSlice(allocator, "␍"),
|
||||||
|
'\n' => try out.appendSlice(allocator, "␊\n"),
|
||||||
|
0x1b => try out.appendSlice(allocator, "␛"),
|
||||||
|
'\t' => try out.appendSlice(allocator, "⇥"),
|
||||||
|
else => if (byte < 0x20) {
|
||||||
|
try out.appendSlice(allocator, "␀");
|
||||||
|
} else try out.append(allocator, byte),
|
||||||
|
};
|
||||||
|
return out.toOwnedSlice(allocator);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hasBareLf(bytes: []const u8) bool {
|
||||||
|
for (bytes, 0..) |byte, i| {
|
||||||
|
if (byte == '\n' and (i == 0 or bytes[i - 1] != '\r')) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: visible terminal controls expose newline and escape bytes" {
|
||||||
|
const debug = try visibleControlsAlloc(std.testing.allocator, "a\nb\r\n\x1b[31m");
|
||||||
|
defer std.testing.allocator.free(debug);
|
||||||
|
try std.testing.expectEqualStrings("a␊\nb␍␊\n␛[31m", debug);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "adversarial: bare LF detector catches raw-mode skew risk" {
|
||||||
|
try std.testing.expect(hasBareLf("a\nb"));
|
||||||
|
try std.testing.expect(!hasBareLf("a\r\nb"));
|
||||||
|
}
|
||||||
+406
@@ -0,0 +1,406 @@
|
|||||||
|
const std = @import("std");
|
||||||
|
|
||||||
|
/// Source-patched from newed night:
|
||||||
|
/// - /opt/repositories/newed/newed-theme/src/default_theme.rs
|
||||||
|
/// - /opt/repositories/newed/newed-theme/src/adapt.rs
|
||||||
|
///
|
||||||
|
/// This is intentionally a tiny local model, not a runtime theme/config system.
|
||||||
|
pub const Color = struct {
|
||||||
|
r: u8,
|
||||||
|
g: u8,
|
||||||
|
b: u8,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Token = enum {
|
||||||
|
normal,
|
||||||
|
muted,
|
||||||
|
accent,
|
||||||
|
selection,
|
||||||
|
panel_bg,
|
||||||
|
panel_border,
|
||||||
|
cursor,
|
||||||
|
diagnostic_error,
|
||||||
|
diagnostic_warning,
|
||||||
|
diagnostic_info,
|
||||||
|
diagnostic_hint,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Style = struct {
|
||||||
|
fg: ?Color = null,
|
||||||
|
bg: ?Color = null,
|
||||||
|
bold: bool = false,
|
||||||
|
italic: bool = false,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const ColorMode = enum {
|
||||||
|
truecolor,
|
||||||
|
color256,
|
||||||
|
color16,
|
||||||
|
mono,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Background = enum {
|
||||||
|
dark,
|
||||||
|
light,
|
||||||
|
unknown,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const TerminalEnv = struct {
|
||||||
|
term: ?[]const u8 = null,
|
||||||
|
colorterm: ?[]const u8 = null,
|
||||||
|
no_color: ?[]const u8 = null,
|
||||||
|
mim_color: ?[]const u8 = null,
|
||||||
|
colorfgbg: ?[]const u8 = null,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const TerminalCapabilities = struct {
|
||||||
|
color_mode: ColorMode,
|
||||||
|
background: Background,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const SgrKind = enum { fg, bg };
|
||||||
|
|
||||||
|
pub const AdaptedColor = union(enum) {
|
||||||
|
rgb: Color,
|
||||||
|
indexed: u8,
|
||||||
|
reset,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub const Palette = struct {
|
||||||
|
normal: Style,
|
||||||
|
muted: Style,
|
||||||
|
accent: Style,
|
||||||
|
selection: Style,
|
||||||
|
panel_bg: Style,
|
||||||
|
panel_border: Style,
|
||||||
|
cursor: Style,
|
||||||
|
diagnostic_error: Style,
|
||||||
|
diagnostic_warning: Style,
|
||||||
|
diagnostic_info: Style,
|
||||||
|
diagnostic_hint: Style,
|
||||||
|
|
||||||
|
pub fn style(self: Palette, token: Token) Style {
|
||||||
|
return switch (token) {
|
||||||
|
.normal => self.normal,
|
||||||
|
.muted => self.muted,
|
||||||
|
.accent => self.accent,
|
||||||
|
.selection => self.selection,
|
||||||
|
.panel_bg => self.panel_bg,
|
||||||
|
.panel_border => self.panel_border,
|
||||||
|
.cursor => self.cursor,
|
||||||
|
.diagnostic_error => self.diagnostic_error,
|
||||||
|
.diagnostic_warning => self.diagnostic_warning,
|
||||||
|
.diagnostic_info => self.diagnostic_info,
|
||||||
|
.diagnostic_hint => self.diagnostic_hint,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// RGB approximations of the named OKLCH anchors in newed default_theme.rs.
|
||||||
|
// The source anchors remain semantic: sumi-ink base, moonlight foreground,
|
||||||
|
// beni/coral accent, cool water selection, and distinct diagnostics.
|
||||||
|
pub const newed_night = Palette{
|
||||||
|
.normal = .{ .fg = rgb(230, 230, 230), .bg = rgb(25, 22, 33) },
|
||||||
|
.muted = .{ .fg = rgb(103, 93, 122), .bg = rgb(25, 22, 33) },
|
||||||
|
.accent = .{ .fg = rgb(235, 119, 84), .bold = true },
|
||||||
|
.selection = .{ .bg = rgb(55, 65, 92) },
|
||||||
|
.panel_bg = .{ .fg = rgb(230, 230, 230), .bg = rgb(20, 18, 26) },
|
||||||
|
.panel_border = .{ .fg = rgb(72, 67, 84), .bg = rgb(25, 22, 33) },
|
||||||
|
.cursor = .{ .fg = rgb(25, 22, 33), .bg = rgb(230, 230, 230) },
|
||||||
|
.diagnostic_error = .{ .fg = rgb(238, 84, 88), .bold = true },
|
||||||
|
.diagnostic_warning = .{ .fg = rgb(231, 168, 78), .bold = true },
|
||||||
|
.diagnostic_info = .{ .fg = rgb(121, 155, 224) },
|
||||||
|
.diagnostic_hint = .{ .fg = rgb(116, 196, 171) },
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn rgb(r: u8, g: u8, b: u8) Color {
|
||||||
|
return .{ .r = r, .g = g, .b = b };
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detectTerminal(env: TerminalEnv) TerminalCapabilities {
|
||||||
|
return .{
|
||||||
|
.color_mode = detectColorMode(env),
|
||||||
|
.background = detectBackground(env),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detectColorMode(env: TerminalEnv) ColorMode {
|
||||||
|
if (env.no_color != null) return .mono;
|
||||||
|
if (env.mim_color) |forced| {
|
||||||
|
if (std.ascii.eqlIgnoreCase(forced, "mono")) return .mono;
|
||||||
|
if (std.ascii.eqlIgnoreCase(forced, "16")) return .color16;
|
||||||
|
if (std.ascii.eqlIgnoreCase(forced, "256")) return .color256;
|
||||||
|
if (std.ascii.eqlIgnoreCase(forced, "truecolor") or std.ascii.eqlIgnoreCase(forced, "24bit")) return .truecolor;
|
||||||
|
}
|
||||||
|
if (env.term) |term| {
|
||||||
|
if (std.mem.eql(u8, term, "dumb")) return .mono;
|
||||||
|
}
|
||||||
|
if (env.colorterm) |colorterm| {
|
||||||
|
if (containsIgnoreCase(colorterm, "truecolor") or containsIgnoreCase(colorterm, "24bit")) return .truecolor;
|
||||||
|
}
|
||||||
|
if (env.term) |term| {
|
||||||
|
if (containsIgnoreCase(term, "truecolor") or containsIgnoreCase(term, "24bit")) return .truecolor;
|
||||||
|
if (containsIgnoreCase(term, "256color")) return .color256;
|
||||||
|
if (containsIgnoreCase(term, "color")) return .color16;
|
||||||
|
}
|
||||||
|
return .color16;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn detectBackground(env: TerminalEnv) Background {
|
||||||
|
const value = env.colorfgbg orelse return .unknown;
|
||||||
|
var fields = std.mem.splitScalar(u8, value, ';');
|
||||||
|
var last: ?[]const u8 = null;
|
||||||
|
while (fields.next()) |field| last = field;
|
||||||
|
const bg = last orelse return .unknown;
|
||||||
|
const code = std.fmt.parseInt(u8, bg, 10) catch return .unknown;
|
||||||
|
return if (code >= 0 and code <= 6) .dark else .light;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fromProcessEnv() TerminalCapabilities {
|
||||||
|
return detectTerminal(.{
|
||||||
|
.term = std.posix.getenv("TERM"),
|
||||||
|
.colorterm = std.posix.getenv("COLORTERM"),
|
||||||
|
.no_color = std.posix.getenv("NO_COLOR"),
|
||||||
|
.mim_color = std.posix.getenv("MIM_COLOR"),
|
||||||
|
.colorfgbg = std.posix.getenv("COLORFGBG"),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn hasAccessibleContrast(style: Style, fallback_bg: Color, minimum_ratio: f32) bool {
|
||||||
|
const fg = style.fg orelse return true;
|
||||||
|
const bg = style.bg orelse fallback_bg;
|
||||||
|
return contrastRatio(fg, bg) >= minimum_ratio;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn adaptColor(color: Color, mode: ColorMode) AdaptedColor {
|
||||||
|
return switch (mode) {
|
||||||
|
.truecolor => .{ .rgb = color },
|
||||||
|
.color256 => .{ .indexed = nearest256(color) },
|
||||||
|
.color16 => .{ .indexed = nearest16(color) },
|
||||||
|
.mono => .reset,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn appendStyleSgr(writer: *std.Io.Writer, style: Style, mode: ColorMode) !void {
|
||||||
|
try writer.writeAll("\x1b[0m");
|
||||||
|
if (mode == .mono) {
|
||||||
|
if (style.bold) try writer.writeAll("\x1b[1m");
|
||||||
|
if (style.italic) try writer.writeAll("\x1b[3m");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (style.bold) try writer.writeAll("\x1b[1m");
|
||||||
|
if (style.italic) try writer.writeAll("\x1b[3m");
|
||||||
|
if (style.fg) |fg| try appendColorSgr(writer, .fg, fg, mode);
|
||||||
|
if (style.bg) |bg| try appendColorSgr(writer, .bg, bg, mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn appendColorSgr(writer: *std.Io.Writer, kind: SgrKind, color: Color, mode: ColorMode) !void {
|
||||||
|
switch (adaptColor(color, mode)) {
|
||||||
|
.rgb => |c| try writer.print("\x1b[{d};2;{d};{d};{d}m", .{ sgrBase(kind), c.r, c.g, c.b }),
|
||||||
|
.indexed => |index| try writer.print("\x1b[{d};5;{d}m", .{ sgrBase(kind), index }),
|
||||||
|
.reset => {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resetSgr() []const u8 {
|
||||||
|
return "\x1b[0m";
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn wrap(allocator: std.mem.Allocator, text: []const u8, style: Style, mode: ColorMode) ![]u8 {
|
||||||
|
var out: std.Io.Writer.Allocating = .init(allocator);
|
||||||
|
defer out.deinit();
|
||||||
|
try appendStyleSgr(&out.writer, style, mode);
|
||||||
|
try out.writer.writeAll(text);
|
||||||
|
try out.writer.writeAll(resetSgr());
|
||||||
|
return out.toOwnedSlice();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn contrastRatio(foreground: Color, background: Color) f32 {
|
||||||
|
const l1 = relativeLuminance(foreground);
|
||||||
|
const l2 = relativeLuminance(background);
|
||||||
|
const lighter = @max(l1, l2);
|
||||||
|
const darker = @min(l1, l2);
|
||||||
|
return (lighter + 0.05) / (darker + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sgrBase(kind: SgrKind) u8 {
|
||||||
|
return switch (kind) {
|
||||||
|
.fg => 38,
|
||||||
|
.bg => 48,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
fn containsIgnoreCase(haystack: []const u8, needle: []const u8) bool {
|
||||||
|
if (needle.len == 0) return true;
|
||||||
|
if (needle.len > haystack.len) return false;
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i + needle.len <= haystack.len) : (i += 1) {
|
||||||
|
if (std.ascii.eqlIgnoreCase(haystack[i .. i + needle.len], needle)) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nearest16(color: Color) u8 {
|
||||||
|
return nearestInTable(color, ansi16[0..]);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nearest256(color: Color) u8 {
|
||||||
|
var best_index: u8 = 0;
|
||||||
|
var best_distance: u32 = std.math.maxInt(u32);
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < 256) : (i += 1) {
|
||||||
|
const candidate = xterm256(@intCast(i));
|
||||||
|
const distance = distanceSquared(color, candidate);
|
||||||
|
if (distance < best_distance) {
|
||||||
|
best_distance = distance;
|
||||||
|
best_index = @intCast(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn nearestInTable(color: Color, table: []const Color) u8 {
|
||||||
|
var best_index: u8 = 0;
|
||||||
|
var best_distance: u32 = std.math.maxInt(u32);
|
||||||
|
for (table, 0..) |candidate, index| {
|
||||||
|
const distance = distanceSquared(color, candidate);
|
||||||
|
if (distance < best_distance) {
|
||||||
|
best_distance = distance;
|
||||||
|
best_index = @intCast(index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best_index;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn distanceSquared(a: Color, b: Color) u32 {
|
||||||
|
const dr: i32 = @as(i32, a.r) - @as(i32, b.r);
|
||||||
|
const dg: i32 = @as(i32, a.g) - @as(i32, b.g);
|
||||||
|
const db: i32 = @as(i32, a.b) - @as(i32, b.b);
|
||||||
|
return @intCast(dr * dr + dg * dg + db * db);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn xterm256(index: u8) Color {
|
||||||
|
if (index < 16) return ansi16[index];
|
||||||
|
if (index >= 232) {
|
||||||
|
const level: u8 = 8 + 10 * (index - 232);
|
||||||
|
return rgb(level, level, level);
|
||||||
|
}
|
||||||
|
const cube = index - 16;
|
||||||
|
const r = cube / 36;
|
||||||
|
const g = (cube % 36) / 6;
|
||||||
|
const b = cube % 6;
|
||||||
|
return rgb(cubeLevel(r), cubeLevel(g), cubeLevel(b));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cubeLevel(value: u8) u8 {
|
||||||
|
return if (value == 0) 0 else 55 + value * 40;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn relativeLuminance(color: Color) f32 {
|
||||||
|
const r = linearize(@as(f32, @floatFromInt(color.r)) / 255.0);
|
||||||
|
const g = linearize(@as(f32, @floatFromInt(color.g)) / 255.0);
|
||||||
|
const b = linearize(@as(f32, @floatFromInt(color.b)) / 255.0);
|
||||||
|
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn linearize(channel: f32) f32 {
|
||||||
|
if (channel <= 0.03928) return channel / 12.92;
|
||||||
|
return std.math.pow(f32, (channel + 0.055) / 1.055, 2.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
const ansi16 = [_]Color{
|
||||||
|
rgb(0, 0, 0),
|
||||||
|
rgb(128, 0, 0),
|
||||||
|
rgb(0, 128, 0),
|
||||||
|
rgb(128, 128, 0),
|
||||||
|
rgb(0, 0, 128),
|
||||||
|
rgb(128, 0, 128),
|
||||||
|
rgb(0, 128, 128),
|
||||||
|
rgb(192, 192, 192),
|
||||||
|
rgb(128, 128, 128),
|
||||||
|
rgb(255, 0, 0),
|
||||||
|
rgb(0, 255, 0),
|
||||||
|
rgb(255, 255, 0),
|
||||||
|
rgb(0, 0, 255),
|
||||||
|
rgb(255, 0, 255),
|
||||||
|
rgb(0, 255, 255),
|
||||||
|
rgb(255, 255, 255),
|
||||||
|
};
|
||||||
|
|
||||||
|
test "regular: default palette exposes semantic newed night tokens" {
|
||||||
|
try std.testing.expectEqual(rgb(25, 22, 33), newed_night.normal.bg.?);
|
||||||
|
try std.testing.expectEqual(rgb(230, 230, 230), newed_night.normal.fg.?);
|
||||||
|
try std.testing.expectEqual(rgb(235, 119, 84), newed_night.style(.accent).fg.?);
|
||||||
|
try std.testing.expect(contrastRatio(newed_night.normal.fg.?, newed_night.normal.bg.?) > 10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: color adaptation is deterministic across terminal modes" {
|
||||||
|
const coral = newed_night.accent.fg.?;
|
||||||
|
try std.testing.expectEqual(AdaptedColor{ .rgb = coral }, adaptColor(coral, .truecolor));
|
||||||
|
try std.testing.expectEqual(AdaptedColor{ .indexed = nearest256(coral) }, adaptColor(coral, .color256));
|
||||||
|
try std.testing.expectEqual(AdaptedColor{ .indexed = nearest16(coral) }, adaptColor(coral, .color16));
|
||||||
|
try std.testing.expectEqual(AdaptedColor.reset, adaptColor(coral, .mono));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: SGR output resets and does not leak styles" {
|
||||||
|
const rendered = try wrap(std.testing.allocator, "kw", newed_night.accent, .truecolor);
|
||||||
|
defer std.testing.allocator.free(rendered);
|
||||||
|
try std.testing.expect(std.mem.startsWith(u8, rendered, resetSgr()));
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, rendered, "\x1b[1m") != null);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, rendered, "\x1b[38;2;235;119;84m") != null);
|
||||||
|
try std.testing.expect(std.mem.endsWith(u8, rendered, resetSgr()));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: mono preserves emphasis without color" {
|
||||||
|
var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
|
||||||
|
defer out.deinit();
|
||||||
|
try appendStyleSgr(&out.writer, newed_night.accent, .mono);
|
||||||
|
const rendered = try out.toOwnedSlice();
|
||||||
|
defer std.testing.allocator.free(rendered);
|
||||||
|
try std.testing.expectEqualStrings("\x1b[0m\x1b[1m", rendered);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: 256 and 16 color SGR use indexed form" {
|
||||||
|
var out256: std.Io.Writer.Allocating = .init(std.testing.allocator);
|
||||||
|
defer out256.deinit();
|
||||||
|
try appendColorSgr(&out256.writer, .fg, newed_night.diagnostic_warning.fg.?, .color256);
|
||||||
|
const rendered256 = try out256.toOwnedSlice();
|
||||||
|
defer std.testing.allocator.free(rendered256);
|
||||||
|
try std.testing.expect(std.mem.startsWith(u8, rendered256, "\x1b[38;5;"));
|
||||||
|
|
||||||
|
var out16: std.Io.Writer.Allocating = .init(std.testing.allocator);
|
||||||
|
defer out16.deinit();
|
||||||
|
try appendColorSgr(&out16.writer, .bg, newed_night.selection.bg.?, .color16);
|
||||||
|
const rendered16 = try out16.toOwnedSlice();
|
||||||
|
defer std.testing.allocator.free(rendered16);
|
||||||
|
try std.testing.expect(std.mem.startsWith(u8, rendered16, "\x1b[48;5;"));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: terminal color-mode detection follows safe precedence" {
|
||||||
|
try std.testing.expectEqual(ColorMode.mono, detectColorMode(.{ .no_color = "1", .colorterm = "truecolor", .term = "xterm-256color" }));
|
||||||
|
try std.testing.expectEqual(ColorMode.mono, detectColorMode(.{ .term = "dumb" }));
|
||||||
|
try std.testing.expectEqual(ColorMode.truecolor, detectColorMode(.{ .colorterm = "truecolor", .term = "xterm-256color" }));
|
||||||
|
try std.testing.expectEqual(ColorMode.truecolor, detectColorMode(.{ .mim_color = "24bit", .term = "screen" }));
|
||||||
|
try std.testing.expectEqual(ColorMode.color256, detectColorMode(.{ .term = "xterm-256color" }));
|
||||||
|
try std.testing.expectEqual(ColorMode.color16, detectColorMode(.{ .term = "xterm-color" }));
|
||||||
|
try std.testing.expectEqual(ColorMode.color16, detectColorMode(.{}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: terminal background detection uses COLORFGBG final field" {
|
||||||
|
try std.testing.expectEqual(Background.dark, detectBackground(.{ .colorfgbg = "15;0" }));
|
||||||
|
try std.testing.expectEqual(Background.light, detectBackground(.{ .colorfgbg = "0;15" }));
|
||||||
|
try std.testing.expectEqual(Background.unknown, detectBackground(.{ .colorfgbg = "broken" }));
|
||||||
|
try std.testing.expectEqual(Background.unknown, detectBackground(.{}));
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: terminal capability detector combines color and background" {
|
||||||
|
const caps = detectTerminal(.{ .term = "xterm-256color", .colorfgbg = "15;0" });
|
||||||
|
try std.testing.expectEqual(ColorMode.color256, caps.color_mode);
|
||||||
|
try std.testing.expectEqual(Background.dark, caps.background);
|
||||||
|
}
|
||||||
|
|
||||||
|
test "regular: accessibility guardrail checks style contrast" {
|
||||||
|
try std.testing.expect(hasAccessibleContrast(newed_night.normal, newed_night.normal.bg.?, 7.0));
|
||||||
|
try std.testing.expect(!hasAccessibleContrast(.{ .fg = rgb(30, 30, 35), .bg = rgb(25, 22, 33) }, newed_night.normal.bg.?, 4.5));
|
||||||
|
try std.testing.expect(hasAccessibleContrast(.{ .bg = rgb(25, 22, 33) }, newed_night.normal.bg.?, 4.5));
|
||||||
|
}
|
||||||
+3706
-66
File diff suppressed because it is too large
Load Diff
@@ -188,3 +188,90 @@ fn freeRows(rows: [][]const u8) void {
|
|||||||
for (rows) |row| allocator.free(row);
|
for (rows) |row| allocator.free(row);
|
||||||
allocator.free(rows);
|
allocator.free(rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
test "regular: ns8 local production smoke covers editor workflow before real-device dogfood" {
|
||||||
|
var client = try tui.Client.initWithIo(allocator, .{ .width = 39, .height = 8 }, std.testing.io);
|
||||||
|
defer client.deinit();
|
||||||
|
|
||||||
|
try client.handleTraceLine("repo_file src/main.zig=pub fn main() void { needle(); }\n");
|
||||||
|
try client.handleTraceLine("repo_file README.md=needle docs\n");
|
||||||
|
try client.handleTraceLine("file_picker");
|
||||||
|
try client.handleTraceLine("list_filter src/main");
|
||||||
|
try client.handleTraceLine("file_open_selected");
|
||||||
|
try client.handleTraceLine("key end");
|
||||||
|
try client.handleInput("i");
|
||||||
|
try client.handleInput("X");
|
||||||
|
try client.handleTraceLine("key escape");
|
||||||
|
try client.handleTraceLine("save_without_format");
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, try client.saved(), "X") != null);
|
||||||
|
|
||||||
|
try client.handleTraceLine("search_text needle");
|
||||||
|
{
|
||||||
|
const frame = try client.render(allocator);
|
||||||
|
defer allocator.free(frame);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, "src/main.zig") != null);
|
||||||
|
try std.testing.expect(frameLinesFit(frame, 39));
|
||||||
|
}
|
||||||
|
|
||||||
|
try client.handleTraceLine("panel_close");
|
||||||
|
try client.handleTraceLine("diagnostic_fixture zls|2|src/main.zig|0|3|warning|demo");
|
||||||
|
try client.handleInput(" ");
|
||||||
|
try client.handleInput("d");
|
||||||
|
{
|
||||||
|
const frame = try client.render(allocator);
|
||||||
|
defer allocator.free(frame);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, "diag:fresh:zls") != null);
|
||||||
|
try std.testing.expect(frameLinesFit(frame, 39));
|
||||||
|
}
|
||||||
|
|
||||||
|
try client.handleTraceLine("panel_close");
|
||||||
|
try client.handleTraceLine("key escape");
|
||||||
|
try client.handleInput(" ");
|
||||||
|
try client.handleInput("t");
|
||||||
|
try client.handleInput("x");
|
||||||
|
{
|
||||||
|
const frame = try client.render(allocator);
|
||||||
|
defer allocator.free(frame);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, frame, "job:status:cancelled:user") != null);
|
||||||
|
try std.testing.expect(frameLinesFit(frame, 39));
|
||||||
|
}
|
||||||
|
|
||||||
|
var guarded = try tui.Client.init(allocator, .{ .width = 39, .height = 8 });
|
||||||
|
defer guarded.deinit();
|
||||||
|
try guarded.handleTraceLine("open abc");
|
||||||
|
try guarded.handleTraceLine("language_edit zls|format|1|0|3|ZLS");
|
||||||
|
try guarded.handleTraceLine("language_edit prettier|format|1|0|3|PRETTY");
|
||||||
|
try guarded.handleTraceLine("save");
|
||||||
|
try std.testing.expectError(tui.Error.NothingSaved, guarded.saved());
|
||||||
|
const guarded_frame = try guarded.render(allocator);
|
||||||
|
defer allocator.free(guarded_frame);
|
||||||
|
try std.testing.expect(std.mem.indexOf(u8, guarded_frame, "action:format:provider") != null);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn frameLinesFit(frame: []const u8, width: usize) bool {
|
||||||
|
var lines = std.mem.splitScalar(u8, frame, '\n');
|
||||||
|
while (lines.next()) |line| if (visibleCells(line) > width) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visibleCells(line: []const u8) usize {
|
||||||
|
var cells: usize = 0;
|
||||||
|
var i: usize = 0;
|
||||||
|
while (i < line.len) {
|
||||||
|
if (line[i] == 0x1b and i + 1 < line.len and line[i + 1] == '[') {
|
||||||
|
i += 2;
|
||||||
|
while (i < line.len) : (i += 1) {
|
||||||
|
const byte = line[i];
|
||||||
|
if ((byte >= 'A' and byte <= 'Z') or (byte >= 'a' and byte <= 'z')) {
|
||||||
|
i += 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const len = std.unicode.utf8ByteSequenceLength(line[i]) catch 1;
|
||||||
|
cells += 1;
|
||||||
|
i += @min(len, line.len - i);
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
# Terminal E2E artifacts
|
||||||
|
|
||||||
|
`zig build terminal-e2e` runs `tools/terminal_e2e.py` against the compiled `mim`
|
||||||
|
binary and writes ignored artifacts under `.zig-cache/terminal-e2e/<scenario>/`.
|
||||||
|
It is the canonical local gate for terminal rendering/input/panel/workflow slices
|
||||||
|
(NS10). Run it after `zig build`, `zig build test`, and `zig build v1-smoke` when
|
||||||
|
a change can affect terminal-visible behavior; pure protocol/headless or docs-only
|
||||||
|
changes may use cheaper checks unless their claim is terminal-visible.
|
||||||
|
|
||||||
|
The command is expected to finish in seconds on a normal development machine
|
||||||
|
(current matrix target: under 30s). A timeout or nonzero exit should be treated as
|
||||||
|
a product regression or harness bug, not a flaky benchmark: inspect the scenario
|
||||||
|
artifact directory named in the failure output.
|
||||||
|
|
||||||
|
Matrix input profiles:
|
||||||
|
|
||||||
|
- `ios-default-qwertz-space-path` means the scenario may use only letters,
|
||||||
|
Shift+letters, and Space from the default iOS software keyboard. Shift is easy
|
||||||
|
to reach on iOS; Esc, Ctrl, Alt, function keys, arrow keys, and symbol-layer
|
||||||
|
punctuation should not appear in this profile. Use this for primary mobile
|
||||||
|
workflows.
|
||||||
|
- `attached-keyboard` is allowed to cover Esc, arrows, Home/End, punctuation, and
|
||||||
|
other physical-key aliases.
|
||||||
|
|
||||||
|
Artifact roles:
|
||||||
|
|
||||||
|
- `manifest.tsv`: stable scenario facts, hashes, bare-LF status, seen ANSI roles,
|
||||||
|
and artifact list.
|
||||||
|
- `visible-controls.normalized.txt`: primary semantic failure artifact. It shows
|
||||||
|
CR as `␍`, LF as `␊`, ESC as `␛`, tabs as `⇥`, and normalizes volatile temp
|
||||||
|
paths.
|
||||||
|
- `transcript.txt`: semantic terminal grid after escape/control interpretation,
|
||||||
|
including debug-only role markers such as `☻` for a cursor cell and `░` for
|
||||||
|
selection.
|
||||||
|
- `screenshot.svg` and `terminal.html`: browser-viewable visual evidence. SVG
|
||||||
|
includes color swatches for seen cursor/current-line/status/selection roles.
|
||||||
|
- `widths.tsv`: per-line semantic width report used to catch viewport overflow.
|
||||||
|
- `keystrokes.tsv`: logical user key-event count and raw byte count for scenario
|
||||||
|
payloads. A CSI sequence such as PageDown counts as one attached-key event
|
||||||
|
while still recording all raw bytes sent.
|
||||||
|
- `saved-files.tsv`: per-file hashes/byte counts for scenarios that assert more
|
||||||
|
than the launched file, such as multi-file coding workflows.
|
||||||
|
- `e2e-prelude.trace` (scenario temp dir only when needed): harness-only trace
|
||||||
|
lines consumed before raw-mode input to seed deterministic provider fixtures;
|
||||||
|
this is not a runtime config surface.
|
||||||
|
- `raw.bin`: exact PTY bytes for low-level debugging.
|
||||||
|
|
||||||
|
Failure triage:
|
||||||
|
|
||||||
|
1. Start with the scenario directory reported by the command.
|
||||||
|
2. Read `manifest.tsv` for hashes, CR/LF status, ANSI roles, key budget, and
|
||||||
|
artifact list.
|
||||||
|
3. Read `visible-controls.normalized.txt` for visible `␍`, `␊`, `␛`, and path
|
||||||
|
normalized raw terminal evidence.
|
||||||
|
4. Read `transcript.txt`, `widths.tsv`, `keystrokes.tsv`, and any
|
||||||
|
`saved-files.tsv` for semantic assertions before inspecting screenshots.
|
||||||
|
5. Use `screenshot.svg`/`terminal.html` to explain what a user would see.
|
||||||
|
|
||||||
|
Golden policy:
|
||||||
|
|
||||||
|
- Investigate before updating any expected artifact or assertion.
|
||||||
|
- Prefer semantic artifacts (`manifest.tsv`, `visible-controls.normalized.txt`,
|
||||||
|
`transcript.txt`, `widths.tsv`, `keystrokes.tsv`, `saved-files.tsv`) for
|
||||||
|
assertions; visual artifacts explain what a user would see.
|
||||||
|
- Updating artifacts is safe only after the change is intentional and the receipt
|
||||||
|
names the changed UI/control-byte behavior.
|
||||||
|
- Adding a mobile scenario must preserve the `ios-default-qwertz-space-path`
|
||||||
|
contract unless the issue explicitly targets attached keyboards.
|
||||||
|
- Bare LF in raw-mode terminal output is a regression unless a scenario explicitly
|
||||||
|
proves a non-raw stream. Raw-mode rendering should use CRLF to avoid diagonal
|
||||||
|
terminal skew.
|
||||||
|
- Scenario `max_key_events` budgets are regression guards for workflow burden,
|
||||||
|
not speed benchmarks. Raise a budget only after explaining the intentional
|
||||||
|
workflow change in the receipt.
|
||||||
|
- Generated artifacts must remain under ignored build output and must not dirty a
|
||||||
|
clean source tree.
|
||||||
Executable
+741
@@ -0,0 +1,741 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""PTY-backed terminal E2E matrix for mim.
|
||||||
|
|
||||||
|
Runs high-signal scenarios through a real pseudo-terminal and writes
|
||||||
|
browser-viewable terminal artifacts. No third-party dependencies.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import dataclasses
|
||||||
|
import hashlib
|
||||||
|
import html
|
||||||
|
import os
|
||||||
|
import pty
|
||||||
|
import re
|
||||||
|
import select
|
||||||
|
import signal
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
ESC = 0x1B
|
||||||
|
TMP_RE = re.compile(r"/tmp/mim-terminal-e2e-[^/]+")
|
||||||
|
CSI_RE = re.compile(r"\x1b\[[0-?]*[ -/]*[@-~]")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass(frozen=True)
|
||||||
|
class Scenario:
|
||||||
|
id: str
|
||||||
|
width: int
|
||||||
|
height: int
|
||||||
|
file_state: str
|
||||||
|
input_profile: str
|
||||||
|
mode_surface: str
|
||||||
|
color_mode: str
|
||||||
|
target_kind: str
|
||||||
|
setup: Callable[[Path], tuple[Path, str | None]]
|
||||||
|
payloads: tuple[bytes, ...]
|
||||||
|
expect_saved: str | None
|
||||||
|
required_raw: tuple[str, ...]
|
||||||
|
forbidden_raw: tuple[str, ...] = ("^[",)
|
||||||
|
required_attrs: tuple[str, ...] = ("cursor", "status", "current-line")
|
||||||
|
max_key_events: int | None = None
|
||||||
|
expect_files: tuple[tuple[str, str], ...] = ()
|
||||||
|
prelude: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
def visible_controls(data: bytes) -> str:
|
||||||
|
out: list[str] = []
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
for ch in text:
|
||||||
|
code = ord(ch)
|
||||||
|
if ch == "\r":
|
||||||
|
out.append("␍")
|
||||||
|
elif ch == "\n":
|
||||||
|
out.append("␊\n")
|
||||||
|
elif ch == "\x1b":
|
||||||
|
out.append("␛")
|
||||||
|
elif ch == "\t":
|
||||||
|
out.append("⇥")
|
||||||
|
elif code < 0x20:
|
||||||
|
out.append(f"␀{code:02x}")
|
||||||
|
else:
|
||||||
|
out.append(ch)
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_visible_controls(text: str) -> str:
|
||||||
|
text = TMP_RE.sub("<TMP>", text)
|
||||||
|
text = re.sub(r"/opt/repositories/mim/zig-out/bin/mim", "<MIM>", text)
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def has_bare_lf(data: bytes) -> bool:
|
||||||
|
return any(byte == 0x0A and (i == 0 or data[i - 1] != 0x0D) for i, byte in enumerate(data))
|
||||||
|
|
||||||
|
|
||||||
|
def strip_csi(text: str) -> str:
|
||||||
|
return CSI_RE.sub("", text)
|
||||||
|
|
||||||
|
|
||||||
|
def classify_sgr(params: str) -> str:
|
||||||
|
if not params or params == "0":
|
||||||
|
return ""
|
||||||
|
parts = [p for p in params.split(";") if p]
|
||||||
|
joined = ";".join(parts)
|
||||||
|
if "48;2;230;230;230" in joined:
|
||||||
|
return "cursor"
|
||||||
|
if "48;2;55;65;92" in joined:
|
||||||
|
return "selection"
|
||||||
|
if "38;2;235;119;84" in joined:
|
||||||
|
return "syntax-keyword"
|
||||||
|
if "38;2;121;155;224" in joined:
|
||||||
|
return "syntax-builtin"
|
||||||
|
if "38;2;116;196;171" in joined:
|
||||||
|
return "syntax-string"
|
||||||
|
if "38;2;103;93;122" in joined:
|
||||||
|
return "syntax-comment"
|
||||||
|
if "38;2;231;168;78" in joined:
|
||||||
|
return "syntax-number"
|
||||||
|
if "38;2;238;84;88" in joined:
|
||||||
|
return "diag-error"
|
||||||
|
if "38;2;116;196;171" in joined:
|
||||||
|
return "diag-hint"
|
||||||
|
if "38;2;121;155;224" in joined:
|
||||||
|
return "diag-info"
|
||||||
|
if "48;2;20;18;26" in joined:
|
||||||
|
return "current-line"
|
||||||
|
if "48;2;235;119;84" in joined:
|
||||||
|
return "status"
|
||||||
|
if "48;2;116;196;171" in joined:
|
||||||
|
return "panel-header"
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def render_debug_cell(cell: "Cell") -> str:
|
||||||
|
if cell.attr == "cursor":
|
||||||
|
return "☻"
|
||||||
|
if cell.attr == "selection":
|
||||||
|
return "░" if cell.ch != " " else "▒"
|
||||||
|
return cell.ch
|
||||||
|
|
||||||
|
|
||||||
|
def self_test() -> None:
|
||||||
|
sample = b"a\nb\r\n\x1b[31m\t\x01"
|
||||||
|
visible = visible_controls(sample)
|
||||||
|
expected = "a␊\nb␍␊\n␛[31m⇥␀01"
|
||||||
|
if visible != expected:
|
||||||
|
raise SystemExit(f"visible control self-test failed: {visible!r} != {expected!r}")
|
||||||
|
if not has_bare_lf(b"a\nb"):
|
||||||
|
raise SystemExit("bare LF self-test failed: did not detect bare LF")
|
||||||
|
if has_bare_lf(b"a\r\nb"):
|
||||||
|
raise SystemExit("bare LF self-test failed: flagged CRLF")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclasses.dataclass
|
||||||
|
class Cell:
|
||||||
|
ch: str = " "
|
||||||
|
attr: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class TerminalGrid:
|
||||||
|
def __init__(self, width: int, height: int) -> None:
|
||||||
|
self.width = width
|
||||||
|
self.height = height
|
||||||
|
self.rows = [[Cell() for _ in range(width)] for _ in range(height)]
|
||||||
|
self.row = 0
|
||||||
|
self.col = 0
|
||||||
|
self.attr = ""
|
||||||
|
self.seen_attrs: set[str] = set()
|
||||||
|
|
||||||
|
def clear(self) -> None:
|
||||||
|
self.rows = [[Cell() for _ in range(self.width)] for _ in range(self.height)]
|
||||||
|
self.row = 0
|
||||||
|
self.col = 0
|
||||||
|
|
||||||
|
def newline(self) -> None:
|
||||||
|
self.row += 1
|
||||||
|
if self.row >= self.height:
|
||||||
|
self.rows.pop(0)
|
||||||
|
self.rows.append([Cell() for _ in range(self.width)])
|
||||||
|
self.row = self.height - 1
|
||||||
|
|
||||||
|
def put_char(self, ch: str) -> None:
|
||||||
|
if ch == "\r":
|
||||||
|
self.col = 0
|
||||||
|
return
|
||||||
|
if ch == "\n":
|
||||||
|
self.newline()
|
||||||
|
return
|
||||||
|
if self.col >= self.width:
|
||||||
|
self.col = 0
|
||||||
|
self.newline()
|
||||||
|
if 0 <= self.row < self.height and 0 <= self.col < self.width:
|
||||||
|
self.rows[self.row][self.col] = Cell(ch, self.attr)
|
||||||
|
self.col += 1
|
||||||
|
|
||||||
|
def feed(self, data: bytes) -> None:
|
||||||
|
i = 0
|
||||||
|
text = data.decode("utf-8", errors="replace")
|
||||||
|
while i < len(text):
|
||||||
|
ch = text[i]
|
||||||
|
if ch == "\x1b" and i + 1 < len(text) and text[i + 1] == "[":
|
||||||
|
j = i + 2
|
||||||
|
while j < len(text) and not ("@" <= text[j] <= "~"):
|
||||||
|
j += 1
|
||||||
|
if j >= len(text):
|
||||||
|
break
|
||||||
|
command = text[j]
|
||||||
|
params = text[i + 2 : j]
|
||||||
|
if command == "H":
|
||||||
|
self.row = 0
|
||||||
|
self.col = 0
|
||||||
|
elif command == "J" and params.endswith("2"):
|
||||||
|
self.clear()
|
||||||
|
elif command == "m":
|
||||||
|
self.attr = classify_sgr(params)
|
||||||
|
if self.attr:
|
||||||
|
self.seen_attrs.add(self.attr)
|
||||||
|
i = j + 1
|
||||||
|
continue
|
||||||
|
self.put_char(ch)
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
def lines(self) -> list[str]:
|
||||||
|
lines = ["".join(render_debug_cell(cell) for cell in row).rstrip() for row in self.rows]
|
||||||
|
if "cursor" in self.seen_attrs and not any("☻" in line for line in lines):
|
||||||
|
lines.append("attr:cursor ☻")
|
||||||
|
if "selection" in self.seen_attrs and not any("░" in line for line in lines):
|
||||||
|
lines.append("attr:selection ░")
|
||||||
|
for attr in sorted(self.seen_attrs):
|
||||||
|
marker = f"attr:{attr}"
|
||||||
|
if not any(marker in line for line in lines):
|
||||||
|
lines.append(marker)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
def colored_rows(self) -> list[list[Cell]]:
|
||||||
|
return self.rows
|
||||||
|
|
||||||
|
def attrs_seen(self) -> set[str]:
|
||||||
|
return set(self.seen_attrs)
|
||||||
|
|
||||||
|
|
||||||
|
def read_available(fd: int, timeout: float = 0.12) -> bytes:
|
||||||
|
chunks: list[bytes] = []
|
||||||
|
deadline = time.monotonic() + timeout
|
||||||
|
while True:
|
||||||
|
remaining = max(0.0, deadline - time.monotonic())
|
||||||
|
ready, _, _ = select.select([fd], [], [], remaining)
|
||||||
|
if not ready:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
chunk = os.read(fd, 65536)
|
||||||
|
except OSError:
|
||||||
|
break
|
||||||
|
if not chunk:
|
||||||
|
break
|
||||||
|
chunks.append(chunk)
|
||||||
|
deadline = time.monotonic() + 0.04
|
||||||
|
return b"".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def spawn_under_pty(argv: list[str], width: int, height: int, color_mode: str, cwd: Path) -> tuple[int, int]:
|
||||||
|
pid, fd = pty.fork()
|
||||||
|
if pid == 0:
|
||||||
|
os.chdir(cwd)
|
||||||
|
os.environ["TERM"] = {"truecolor": "xterm-256color", "mono": "vt100"}.get(color_mode, "xterm-256color")
|
||||||
|
os.environ["COLORTERM"] = "truecolor" if color_mode == "truecolor" else ""
|
||||||
|
try:
|
||||||
|
import fcntl
|
||||||
|
import struct
|
||||||
|
import termios
|
||||||
|
|
||||||
|
winsize = struct.pack("HHHH", height, width, 0, 0)
|
||||||
|
fcntl.ioctl(0, termios.TIOCSWINSZ, winsize)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
os.execv(argv[0], argv)
|
||||||
|
return pid, fd
|
||||||
|
|
||||||
|
|
||||||
|
def svg_for_cells(rows: list[list[Cell]], width: int, height: int, seen_attrs: set[str]) -> str:
|
||||||
|
cell_w = 9
|
||||||
|
cell_h = 18
|
||||||
|
pad = 12
|
||||||
|
svg_w = pad * 2 + width * cell_w
|
||||||
|
svg_h = pad * 2 + height * cell_h
|
||||||
|
body: list[str] = []
|
||||||
|
fills = {"cursor": "#f5c55c", "selection": "#40608c", "current-line": "#1a1f2b", "status": "#7ee787"}
|
||||||
|
text_fills = {"cursor": "#12161e", "status": "#12161e"}
|
||||||
|
for attr_idx, attr in enumerate(sorted(seen_attrs)):
|
||||||
|
fill = fills.get(attr)
|
||||||
|
if fill:
|
||||||
|
x = pad + attr_idx * 120
|
||||||
|
body.append(f'<rect x="{x}" y="2" width="16" height="8" fill="{fill}"/>')
|
||||||
|
body.append(f'<text x="{x + 20}" y="10" xml:space="preserve"><tspan fill="#d6deeb">{html.escape(attr)}</tspan></text>')
|
||||||
|
for row_idx, row in enumerate(rows[:height]):
|
||||||
|
y = pad + row_idx * cell_h
|
||||||
|
for col_idx, cell in enumerate(row[:width]):
|
||||||
|
fill = fills.get(cell.attr)
|
||||||
|
if fill:
|
||||||
|
body.append(f'<rect x="{pad + col_idx * cell_w}" y="{y}" width="{cell_w}" height="{cell_h}" fill="{fill}"/>')
|
||||||
|
line = "".join(cell.ch for cell in row[:width]).rstrip()
|
||||||
|
if line:
|
||||||
|
spans: list[str] = []
|
||||||
|
for cell in row[:len(line)]:
|
||||||
|
fill = text_fills.get(cell.attr, "#d6deeb")
|
||||||
|
spans.append(f'<tspan fill="{fill}">{html.escape(cell.ch)}</tspan>')
|
||||||
|
body.append(f'<text x="{pad}" y="{pad + (row_idx + 1) * cell_h - 4}" xml:space="preserve">{"".join(spans)}</text>')
|
||||||
|
return "\n".join([
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{svg_w}" height="{svg_h}" viewBox="0 0 {svg_w} {svg_h}">',
|
||||||
|
'<rect width="100%" height="100%" fill="#12161e"/>',
|
||||||
|
'<style>text{font-family:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;font-size:14px}</style>',
|
||||||
|
*body,
|
||||||
|
"</svg>",
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
|
def html_for_lines(lines: list[str], raw_svg_name: str, scenario: Scenario) -> str:
|
||||||
|
escaped = html.escape("\n".join(lines))
|
||||||
|
meta = html.escape(f"{scenario.id} {scenario.width}x{scenario.height} {scenario.file_state} {scenario.input_profile} {scenario.mode_surface} {scenario.color_mode}")
|
||||||
|
return f"""<!doctype html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>mim terminal-e2e {html.escape(scenario.id)}</title>
|
||||||
|
<style>
|
||||||
|
body {{ margin: 0; background: #0f131a; color: #d6deeb; }}
|
||||||
|
.meta {{ color: #7ee787; font: 12px ui-monospace, monospace; padding: 8px 12px 0; }}
|
||||||
|
.terminal {{ white-space: pre; font: 14px/18px ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; padding: 12px; }}
|
||||||
|
a {{ color: #7ee787; }}
|
||||||
|
</style>
|
||||||
|
<div class="meta">{meta}</div>
|
||||||
|
<div class="terminal">{escaped}</div>
|
||||||
|
<p><a href="{html.escape(raw_svg_name)}">SVG screenshot artifact</a></p>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def payload_label(payload: bytes) -> str:
|
||||||
|
if payload == b" ":
|
||||||
|
return "Space"
|
||||||
|
if payload == b"\x1b":
|
||||||
|
return "Esc"
|
||||||
|
if payload == b"\r":
|
||||||
|
return "Enter"
|
||||||
|
if payload == b"\t":
|
||||||
|
return "Tab"
|
||||||
|
names = {
|
||||||
|
b"\x1b[A": "ArrowUp",
|
||||||
|
b"\x1b[B": "ArrowDown",
|
||||||
|
b"\x1b[C": "ArrowRight",
|
||||||
|
b"\x1b[D": "ArrowLeft",
|
||||||
|
b"\x1b[F": "End",
|
||||||
|
b"\x1b[H": "Home",
|
||||||
|
b"\x1b[5~": "PageUp",
|
||||||
|
b"\x1b[6~": "PageDown",
|
||||||
|
}
|
||||||
|
if payload in names:
|
||||||
|
return names[payload]
|
||||||
|
try:
|
||||||
|
text = payload.decode("utf-8")
|
||||||
|
except UnicodeDecodeError:
|
||||||
|
return "bytes"
|
||||||
|
if text.isprintable():
|
||||||
|
return text
|
||||||
|
return "bytes"
|
||||||
|
|
||||||
|
|
||||||
|
def keystroke_rows(scenario: Scenario) -> tuple[list[str], int, int]:
|
||||||
|
rows = ["step\tkey_event\traw_bytes\tbytes_hex\tutf8"]
|
||||||
|
raw_bytes = 0
|
||||||
|
for idx, payload in enumerate(scenario.payloads, start=1):
|
||||||
|
raw_bytes += len(payload)
|
||||||
|
utf8 = payload.decode("utf-8", errors="replace")
|
||||||
|
visible = visible_controls(payload).replace("\n", "\\n")
|
||||||
|
rows.append(f"{idx}\t{payload_label(payload)}\t{len(payload)}\t{payload.hex()}\t{visible or utf8}")
|
||||||
|
return rows, len(scenario.payloads), raw_bytes
|
||||||
|
|
||||||
|
|
||||||
|
def write_artifacts(out_dir: Path, scenario: Scenario, transcript: bytes, lines: list[str], rows: list[list[Cell]], seen_attrs: set[str], saved: str | None, saved_files: dict[str, str] | None = None) -> None:
|
||||||
|
visible = visible_controls(transcript)
|
||||||
|
normalized_visible = normalize_visible_controls(visible)
|
||||||
|
raw_seen_attrs = set(seen_attrs)
|
||||||
|
for sgr, attr in (
|
||||||
|
("48;2;230;230;230", "cursor"),
|
||||||
|
("48;2;55;65;92", "selection"),
|
||||||
|
("48;2;20;18;26", "current-line"),
|
||||||
|
("48;2;235;119;84", "status"),
|
||||||
|
):
|
||||||
|
if sgr in normalized_visible:
|
||||||
|
raw_seen_attrs.add(attr)
|
||||||
|
if "cursor" in raw_seen_attrs and not any("☻" in line for line in lines):
|
||||||
|
lines = [*lines, "attr:cursor ☻"]
|
||||||
|
if "selection" in raw_seen_attrs and not any("░" in line for line in lines):
|
||||||
|
lines = [*lines, "attr:selection ░"]
|
||||||
|
key_rows, key_events, raw_input_bytes = keystroke_rows(scenario)
|
||||||
|
budget_status = "unbudgeted"
|
||||||
|
if scenario.max_key_events is not None:
|
||||||
|
budget_status = "pass" if key_events <= scenario.max_key_events else "fail"
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
(out_dir / "raw.bin").write_bytes(transcript)
|
||||||
|
(out_dir / "visible-controls.txt").write_text(visible, encoding="utf-8")
|
||||||
|
(out_dir / "visible-controls.normalized.txt").write_text(normalized_visible, encoding="utf-8")
|
||||||
|
(out_dir / "transcript.txt").write_text("\n".join(lines) + "\n", encoding="utf-8")
|
||||||
|
(out_dir / "screenshot.svg").write_text(svg_for_cells(rows, scenario.width, scenario.height, raw_seen_attrs), encoding="utf-8")
|
||||||
|
(out_dir / "terminal.html").write_text(html_for_lines(lines, "screenshot.svg", scenario), encoding="utf-8")
|
||||||
|
width_report = ["line width text"]
|
||||||
|
for idx, line in enumerate(lines, start=1):
|
||||||
|
width_report.append(f"{idx} {len(line)} {line}")
|
||||||
|
(out_dir / "widths.tsv").write_text("\n".join(width_report) + "\n", encoding="utf-8")
|
||||||
|
(out_dir / "keystrokes.tsv").write_text("\n".join(key_rows) + "\n", encoding="utf-8")
|
||||||
|
saved_files = saved_files or {}
|
||||||
|
if saved_files:
|
||||||
|
saved_rows = ["path\tsha256\tbytes"]
|
||||||
|
for rel_path, contents in sorted(saved_files.items()):
|
||||||
|
saved_rows.append(f"{rel_path}\t{hashlib.sha256(contents.encode('utf-8')).hexdigest()}\t{len(contents.encode('utf-8'))}")
|
||||||
|
(out_dir / "saved-files.tsv").write_text("\n".join(saved_rows) + "\n", encoding="utf-8")
|
||||||
|
snapshot = [
|
||||||
|
f"scenario\t{scenario.id}",
|
||||||
|
f"viewport\t{scenario.width}x{scenario.height}",
|
||||||
|
f"file_state\t{scenario.file_state}",
|
||||||
|
f"input_profile\t{scenario.input_profile}",
|
||||||
|
f"mode_surface\t{scenario.mode_surface}",
|
||||||
|
f"color_mode\t{scenario.color_mode}",
|
||||||
|
f"raw_sha256\t{hashlib.sha256(transcript).hexdigest()}",
|
||||||
|
f"bare_lf\t{str(has_bare_lf(transcript)).lower()}",
|
||||||
|
f"saved_sha256\t{hashlib.sha256((saved or '').encode('utf-8')).hexdigest()}",
|
||||||
|
f"attrs_seen\t{','.join(sorted(raw_seen_attrs))}",
|
||||||
|
f"key_events\t{key_events}",
|
||||||
|
f"raw_input_bytes\t{raw_input_bytes}",
|
||||||
|
f"max_key_events\t{scenario.max_key_events if scenario.max_key_events is not None else ''}",
|
||||||
|
f"key_budget\t{budget_status}",
|
||||||
|
f"saved_files\t{','.join(sorted(saved_files.keys()))}",
|
||||||
|
"artifacts\traw.bin visible-controls.txt visible-controls.normalized.txt transcript.txt screenshot.svg terminal.html widths.tsv keystrokes.tsv saved-files.tsv manifest.tsv",
|
||||||
|
]
|
||||||
|
(out_dir / "manifest.tsv").write_text("key\tvalue\n" + "\n".join(snapshot) + "\n", encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def setup_empty(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "empty.txt"
|
||||||
|
target.write_text("", encoding="utf-8")
|
||||||
|
return target, ""
|
||||||
|
|
||||||
|
|
||||||
|
def setup_existing(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "existing.txt"
|
||||||
|
target.write_text("alpha\nbeta\n", encoding="utf-8")
|
||||||
|
return target, "alpha\nbeta\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_new(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
return tmp / "new-file.txt", ""
|
||||||
|
|
||||||
|
|
||||||
|
def setup_directory(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
root = tmp / "repo"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "one.zig").write_text("pub fn main() void {}\n", encoding="utf-8")
|
||||||
|
return root, None
|
||||||
|
|
||||||
|
|
||||||
|
def setup_short(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "short.txt"
|
||||||
|
target.write_text("abcdef\n", encoding="utf-8")
|
||||||
|
return target, "abcdef\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_zig_syntax(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "syntax.zig"
|
||||||
|
text = "fn main() void { return \"std\"; // hi }\n"
|
||||||
|
target.write_text(text, encoding="utf-8")
|
||||||
|
return target, text
|
||||||
|
|
||||||
|
|
||||||
|
def setup_brackets(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "brackets.txt"
|
||||||
|
target.write_text("(ab)\n", encoding="utf-8")
|
||||||
|
return target, "(ab)\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_indented(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "indent.txt"
|
||||||
|
target.write_text(" item\n", encoding="utf-8")
|
||||||
|
return target, " item\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_tabbed(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "tabs.txt"
|
||||||
|
target.write_text("\titem\n", encoding="utf-8")
|
||||||
|
return target, "\titem\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_long(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "long.txt"
|
||||||
|
target.write_text("l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n", encoding="utf-8")
|
||||||
|
return target, "l1\nl2\nl3\nl4\nl5\nl6\nl7\nl8\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_tall(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "tall.txt"
|
||||||
|
text = "".join(f"l{i}\n" for i in range(1, 17))
|
||||||
|
target.write_text(text, encoding="utf-8")
|
||||||
|
return target, text
|
||||||
|
|
||||||
|
|
||||||
|
def setup_long_line(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "wide.txt"
|
||||||
|
target.write_text("abcdefghijklmnopqrstuvwxyz0123456789\n", encoding="utf-8")
|
||||||
|
return target, "abcdefghijklmnopqrstuvwxyz0123456789\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_ragged(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "ragged.txt"
|
||||||
|
target.write_text("abcdef\nxy\n123456\n", encoding="utf-8")
|
||||||
|
return target, "abcdef\nxy\n123456\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_multifile(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
root = tmp / "repo"
|
||||||
|
root.mkdir()
|
||||||
|
(root / "alpha.zig").write_text("alpha\n", encoding="utf-8")
|
||||||
|
beta = root / "beta.zig"
|
||||||
|
beta.write_text("beta\n", encoding="utf-8")
|
||||||
|
return beta, "beta\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_lsp_assist(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
target = tmp / "lsp.zig"
|
||||||
|
target.write_text("abc\n", encoding="utf-8")
|
||||||
|
return target, "abc\n"
|
||||||
|
|
||||||
|
|
||||||
|
def setup_project_search(tmp: Path) -> tuple[Path, str | None]:
|
||||||
|
root = tmp / "repo"
|
||||||
|
root.mkdir()
|
||||||
|
main = root / "main.zig"
|
||||||
|
main.write_text("main\n", encoding="utf-8")
|
||||||
|
(root / "target.zig").write_text("findme\n", encoding="utf-8")
|
||||||
|
return main, "main\n"
|
||||||
|
|
||||||
|
|
||||||
|
MOBILE_SYMBOL_SAVE_QUIT = tuple(bytes([b]) for b in b" ps w q")
|
||||||
|
MOBILE_REPLACE_SAVE_QUIT = tuple(bytes([b]) for b in b"rx w q")
|
||||||
|
ATTACHED_SAVE_QUIT = (b"i", b"\x1b[F", b"!", b"\x1b", b" ", b"w", b" ", b"q")
|
||||||
|
PANEL_QUIT = (b" ", b"q")
|
||||||
|
LSP_ASSIST_PRELUDE = "\n".join((
|
||||||
|
"lsp_hover_fixture zls|add(lhs, rhs)|Very long documentation that should stay viewport safe.",
|
||||||
|
"lsp_signature_fixture zls|add(lhs: i32, rhs: i32) active=rhs",
|
||||||
|
"diagnostic_fixture zls|1|lsp.zig|0|3|warning|demo",
|
||||||
|
"language_default_format zls",
|
||||||
|
"language_edit zls|format|1|0|3|ZLS",
|
||||||
|
))
|
||||||
|
|
||||||
|
IOS_DEFAULT_KEYS = set(b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ ")
|
||||||
|
|
||||||
|
SCENARIOS = [
|
||||||
|
Scenario("raw-key-chrome-crlf", 48, 16, "empty", "ios-default-qwertz-space-path", "normal-symbol-save", "truecolor", "file", setup_empty, MOBILE_SYMBOL_SAVE_QUIT, "/", ("1│", "☻", "mode:normal"), max_key_events=7),
|
||||||
|
Scenario("coding-symbol-rail", 56, 12, "empty", "ios-default-qwertz-space-path", "normal-symbols", "truecolor", "file", setup_empty, (b" ", b"p", b"f", b" ", b"p", b"x", b" ", b"p", b"a", b" ", b"p", b"h", b" ", b"p", b"d", b" ", b"p", b"r", b" ", b"w", b" ", b"q"), "//*&#$@", ("symbols:", "mode:normal", "☻"), max_key_events=24),
|
||||||
|
Scenario("syntax-highlight-colors", 64, 10, "zig-syntax", "attached-keyboard", "syntax", "truecolor", "file", setup_zig_syntax, (b" ", b"q"), "fn main() void { return \"std\"; // hi }\n", ("mode:normal",), required_attrs=("cursor", "status", "current-line", "syntax-keyword", "syntax-builtin", "syntax-string", "syntax-comment"), max_key_events=2),
|
||||||
|
Scenario("ios-normal-replace-shift-path", 44, 12, "existing", "ios-default-qwertz-space-path", "normal-replace", "truecolor", "file", setup_existing, MOBILE_REPLACE_SAVE_QUIT, "xlpha\nbeta", ("1│", "☻", "mode:normal")),
|
||||||
|
Scenario("attached-existing-medium", 72, 18, "existing", "attached-keyboard", "insert-normal", "truecolor", "file", setup_existing, ATTACHED_SAVE_QUIT, "alpha!\nbeta", ("1│", "2│", "☻"), max_key_events=8),
|
||||||
|
Scenario("new-file-mono-narrow", 40, 12, "new", "ios-default-qwertz-space-path", "normal-symbol-save", "mono", "file", setup_new, MOBILE_SYMBOL_SAVE_QUIT, "/", ("1│", "☻", "mode:normal")),
|
||||||
|
Scenario("esc-attached-key-mode-switch", 56, 14, "empty", "attached-keyboard", "insert-normal", "truecolor", "file", setup_empty, (b"i", b"e", b"s", b"c", b"\x1b", b" ", b"w", b" ", b"q"), "esc", ("1│", "☻", "mode:normal")),
|
||||||
|
Scenario("ctrl-bracket-mode-switch", 56, 14, "empty", "attached-keyboard", "insert-normal", "truecolor", "file", setup_empty, (b"i", b"c", b"t", b"r", b"l", b"\x1b", b" ", b"w", b" ", b"q"), "ctrl", ("1│", "☻", "mode:normal"), forbidden_raw=("^[",)),
|
||||||
|
Scenario("attached-cursor-left-insert", 56, 14, "empty", "attached-keyboard", "insert-cursor", "truecolor", "file", setup_empty, (b"i", b"a", b"b", b"\x1b[D", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "aXb", ("1│", "☻", "aX", "mode:normal")),
|
||||||
|
Scenario("normal-A-append", 56, 14, "existing", "attached-keyboard", "append-eol", "truecolor", "file", setup_existing, (b"A", b"!", b"\x1b", b" ", b"w", b" ", b"q"), "alpha!\nbeta", ("1│", "☻", "alpha")),
|
||||||
|
Scenario("insert-enter-indent", 56, 14, "indented", "attached-keyboard", "newline-indent", "truecolor", "file", setup_indented, (b"A", b"\r", b"x", b"\x1b", b" ", b"w", b" ", b"q"), " item\n x", ("1│", "2│", "☻")),
|
||||||
|
Scenario("insert-tab-expands-spaces", 56, 14, "tabbed", "attached-keyboard", "tab-indent", "truecolor", "file", setup_tabbed, (b"A", b"\t", b"x", b"\x1b", b" ", b"w", b" ", b"q"), "\titem x", ("1│", "☻")),
|
||||||
|
Scenario("dot-repeat-local-edit", 56, 12, "short", "attached-keyboard", "repeat", "truecolor", "file", setup_short, (b"i", b"X", b"\x1b", b"w", b".", b" ", b"w", b" ", b"q"), "XabcdefX", ("mode:normal", "☻")),
|
||||||
|
Scenario("page-keys-move-cursor", 56, 10, "long", "attached-keyboard", "page-keys", "truecolor", "file", setup_long, (b"\x1b[6~", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "l1\nl2\nl3\nl4\nl5\nl6\nl7\nXl8", ("8│", "☻")),
|
||||||
|
Scenario("counted-move-inserts-at-count", 56, 12, "short", "attached-keyboard", "counts", "truecolor", "file", setup_short, (b"3", b"l", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "abcXdef", ("1│", "☻")),
|
||||||
|
Scenario("percent-match-jump", 56, 12, "brackets", "attached-keyboard", "match-jump", "truecolor", "file", setup_brackets, (b"%", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "(abX)", ("1│", "☻")),
|
||||||
|
Scenario("select-mode-motions", 56, 12, "short", "attached-keyboard", "select", "truecolor", "file", setup_short, (b"L", b"s", b"H", b"L", b"n", b" ", b"q"), "abcdef\n", ("attr:selection", "░", "mode:normal")),
|
||||||
|
Scenario("select-object-word-rail", 56, 12, "short", "attached-keyboard", "select-object", "truecolor", "file", setup_short, (b"s", b"o", b"w", b"n", b" ", b"q"), "abcdef\n", ("attr:selection", "mode:normal")),
|
||||||
|
Scenario("first-nonblank-line-motion", 56, 12, "indented", "attached-keyboard", "line-edges", "truecolor", "file", setup_indented, (b"L", b"^", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), " Xitem", ("1│", "☻", "mode:normal")),
|
||||||
|
Scenario("horizontal-cursor-scroll", 24, 8, "wide-line", "attached-keyboard", "horizontal-scroll", "truecolor", "file", setup_long_line, (b"L", b" ", b"q"), "abcdefghijklmnopqrstuvwxyz0123456789\n", ("0123456789", "☻", "mode:normal")),
|
||||||
|
Scenario("vertical-motion-preferred-column", 56, 12, "ragged", "attached-keyboard", "motion", "truecolor", "file", setup_ragged, (b"L", b"j", b"j", b"i", b"X", b"\x1b", b" ", b"w", b" ", b"q"), "abcdef\nxy\n123456X", ("1│", "3│", "☻", "mode:normal")),
|
||||||
|
Scenario("document-half-page-motions", 56, 10, "tall", "attached-keyboard", "motion", "truecolor", "file", setup_tall, (b"G", b"i", b"X", b"\x1b", b"g", b"g", b"\x04", b"i", b"Y", b"\x1b", b"G", b"g", b"u", b"i", b"Z", b"\x1b", b" ", b"w", b" ", b"q"), "l1\nl2\nl3\nl4\nl5\nZl6\nl7\nl8\nl9\nl10\nYl11\nl12\nl13\nl14\nl15\nXl16", ("11│", "16│", "☻", "mode:normal")),
|
||||||
|
Scenario("multi-file-coding-loop", 72, 16, "repo-multifile", "attached-keyboard", "multi-file", "truecolor", "file", setup_multifile, (b"A", b"?", b"\x1b", b" ", b"w", b" ", b"o", b"r", b"e", b"p", b"o", b"/", b"a", b"l", b"p", b"h", b"a", b".", b"z", b"i", b"g", b"\r", b"A", b"!", b"\x1b", b" ", b"w", b" ", b"q"), None, ("alpha.zig", "beta", "☻", "mode:normal"), max_key_events=30, expect_files=(("alpha.zig", "alpha!\n"), ("beta.zig", "beta?"))),
|
||||||
|
Scenario("lsp-assisted-coding", 60, 12, "lsp-fixture", "attached-keyboard", "lsp", "truecolor", "file", setup_lsp_assist, (b" ", b"l", b"h", b" ", b"l", b"s", b" ", b"l", b"n", b"q", b" ", b"l", b"p", b"q", b" ", b"d", b"q", b" ", b"d", b"n", b" ", b"l", b"f", b" ", b"w", b" ", b"q"), "ZLS", ("[zls] add(lhs, rhs)", "[zls] add(lhs: i32, rhs: i32) active=rhs", "panel [lsp-parameter]", "diag:fresh:zls", "format:zls:applied", "ZLS", "mode:normal"), max_key_events=27, prelude=LSP_ASSIST_PRELUDE),
|
||||||
|
Scenario("project-search-panel", 60, 12, "repo-search", "attached-keyboard", "project-search", "truecolor", "file", setup_project_search, (b" ", b"s", b"p", b"f", b"i", b"n", b"d", b"m", b"e", b"\r", b"q", b" ", b"q"), "main\n", ("panel [search]", "target.zig", "findme", "mode:normal"), max_key_events=13),
|
||||||
|
Scenario("file-and-pi-panels", 60, 12, "repo-multifile", "attached-keyboard", "panels", "truecolor", "file", setup_multifile, (b" ", b"f", b"q", b" ", b"a", b"q", b" ", b"q"), "beta\n", ("panel [files]", "alpha.zig", "panel [pi]", "pi_ask:explain_current_buffer", "mode:normal"), required_attrs=("panel-header", "current-line"), max_key_events=8),
|
||||||
|
Scenario("job-cancel-output-panel", 60, 12, "job", "attached-keyboard", "job-output", "truecolor", "file", setup_empty, (b" ", b"j", b"x", b"q", b" ", b"q"), "", ("panel [job-output]", "job:profile:build", "job:status:cancelled:user", "mode:normal"), max_key_events=6),
|
||||||
|
Scenario("dirty-discard-shift-q", 52, 12, "empty", "ios-default-qwertz-space-path", "dirty-discard", "truecolor", "file", setup_empty, tuple(bytes([b]) for b in b" ps Q"), "", ("1│", "☻")),
|
||||||
|
Scenario("directory-panel-narrow", 52, 12, "directory", "ios-default-qwertz-space-path", "panel", "mono", "directory", setup_directory, PANEL_QUIT, None, ("file", "one.zig"), required_attrs=()),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def assert_input_profile_is_ergonomic(scenario: Scenario) -> None:
|
||||||
|
if scenario.input_profile != "ios-default-qwertz-space-path":
|
||||||
|
return
|
||||||
|
for payload in scenario.payloads:
|
||||||
|
for byte in payload:
|
||||||
|
if byte not in IOS_DEFAULT_KEYS:
|
||||||
|
raise SystemExit(
|
||||||
|
f"{scenario.id}: iOS default keyboard scenario uses non-default key byte 0x{byte:02x}; "
|
||||||
|
"move it to an attached-keyboard scenario or redesign the mobile path"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def run_one(mim: Path, root_out: Path, scenario: Scenario) -> tuple[str, Path]:
|
||||||
|
assert_input_profile_is_ergonomic(scenario)
|
||||||
|
out_dir = root_out / scenario.id
|
||||||
|
with tempfile.TemporaryDirectory(prefix="mim-terminal-e2e-") as tmp_name:
|
||||||
|
tmp = Path(tmp_name)
|
||||||
|
target, expected_initial = scenario.setup(tmp)
|
||||||
|
_ = expected_initial
|
||||||
|
launch_arg = target.relative_to(tmp).as_posix()
|
||||||
|
argv = [str(mim), launch_arg]
|
||||||
|
if scenario.prelude:
|
||||||
|
prelude_path = tmp / "e2e-prelude.trace"
|
||||||
|
prelude_path.write_text(scenario.prelude + "\n", encoding="utf-8")
|
||||||
|
argv = [str(mim), "--e2e-prelude-file", prelude_path.name, launch_arg]
|
||||||
|
pid, fd = spawn_under_pty(argv, scenario.width, scenario.height, scenario.color_mode, tmp)
|
||||||
|
transcript = bytearray()
|
||||||
|
grid = TerminalGrid(scenario.width, scenario.height)
|
||||||
|
try:
|
||||||
|
for payload in (b"", *scenario.payloads):
|
||||||
|
if payload:
|
||||||
|
os.write(fd, payload)
|
||||||
|
chunk = read_available(fd)
|
||||||
|
transcript.extend(chunk)
|
||||||
|
grid.feed(chunk)
|
||||||
|
deadline = time.monotonic() + 2.0
|
||||||
|
while time.monotonic() < deadline:
|
||||||
|
done_pid, status = os.waitpid(pid, os.WNOHANG)
|
||||||
|
chunk = read_available(fd, 0.03)
|
||||||
|
transcript.extend(chunk)
|
||||||
|
grid.feed(chunk)
|
||||||
|
if done_pid == pid:
|
||||||
|
if status != 0:
|
||||||
|
write_artifacts(out_dir, scenario, bytes(transcript), grid.lines(), grid.colored_rows(), grid.attrs_seen(), None)
|
||||||
|
raise SystemExit(f"{scenario.id}: mim exited non-zero: status={status}; artifacts={out_dir}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
write_artifacts(out_dir, scenario, bytes(transcript), grid.lines(), grid.colored_rows(), grid.attrs_seen(), None)
|
||||||
|
os.kill(pid, signal.SIGTERM)
|
||||||
|
raise SystemExit(f"{scenario.id}: mim did not exit after script; artifacts={out_dir}")
|
||||||
|
finally:
|
||||||
|
try:
|
||||||
|
os.close(fd)
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
saved: str | None = None
|
||||||
|
if scenario.target_kind == "file" and target.exists():
|
||||||
|
saved = target.read_text(encoding="utf-8")
|
||||||
|
saved_files: dict[str, str] = {}
|
||||||
|
for rel_path, _expected in scenario.expect_files:
|
||||||
|
file_path = (target / rel_path) if scenario.target_kind == "directory" else (target.parent / rel_path)
|
||||||
|
if file_path.exists():
|
||||||
|
saved_files[rel_path] = file_path.read_text(encoding="utf-8")
|
||||||
|
lines = grid.lines()
|
||||||
|
raw = bytes(transcript)
|
||||||
|
write_artifacts(out_dir, scenario, raw, lines, grid.colored_rows(), grid.attrs_seen(), saved, saved_files)
|
||||||
|
failures: list[str] = []
|
||||||
|
raw_text = raw.decode("utf-8", errors="ignore")
|
||||||
|
_, key_events, _ = keystroke_rows(scenario)
|
||||||
|
if scenario.max_key_events is not None and key_events > scenario.max_key_events:
|
||||||
|
failures.append(f"key budget exceeded: {key_events} > {scenario.max_key_events}")
|
||||||
|
if scenario.expect_saved is not None and saved != scenario.expect_saved:
|
||||||
|
failures.append(f"saved file mismatch: {saved!r} != {scenario.expect_saved!r}")
|
||||||
|
for rel_path, expected in scenario.expect_files:
|
||||||
|
actual = saved_files.get(rel_path)
|
||||||
|
if actual != expected:
|
||||||
|
failures.append(f"saved file mismatch for {rel_path}: {actual!r} != {expected!r}")
|
||||||
|
if has_bare_lf(raw):
|
||||||
|
failures.append("raw terminal output contains bare LF; expected CRLF in raw mode")
|
||||||
|
plain_text = strip_csi(raw_text)
|
||||||
|
raw_attrs: set[str] = set()
|
||||||
|
for sgr, attr in (
|
||||||
|
("48;2;230;230;230", "cursor"),
|
||||||
|
("48;2;55;65;92", "selection"),
|
||||||
|
("48;2;20;18;26", "current-line"),
|
||||||
|
("48;2;235;119;84", "status"),
|
||||||
|
("48;2;116;196;171", "panel-header"),
|
||||||
|
("38;2;235;119;84", "syntax-keyword"),
|
||||||
|
("38;2;121;155;224", "syntax-builtin"),
|
||||||
|
("38;2;116;196;171", "syntax-string"),
|
||||||
|
("38;2;103;93;122", "syntax-comment"),
|
||||||
|
("38;2;231;168;78", "syntax-number"),
|
||||||
|
("38;2;238;84;88", "diag-error"),
|
||||||
|
("38;2;116;196;171", "diag-hint"),
|
||||||
|
("38;2;121;155;224", "diag-info"),
|
||||||
|
):
|
||||||
|
if sgr in raw_text:
|
||||||
|
raw_attrs.add(attr)
|
||||||
|
if "cursor" in raw_attrs:
|
||||||
|
plain_text += "\nattr:cursor ☻"
|
||||||
|
if "selection" in raw_attrs:
|
||||||
|
plain_text += "\nattr:selection ░"
|
||||||
|
for required in scenario.required_raw:
|
||||||
|
if required not in plain_text and required not in raw_text:
|
||||||
|
failures.append(f"required terminal text missing: {required!r}")
|
||||||
|
for attr in scenario.required_attrs:
|
||||||
|
if attr not in raw_attrs:
|
||||||
|
failures.append(f"required ANSI role missing: {attr}")
|
||||||
|
if scenario.color_mode == "truecolor" and "\x1b[38;2;" not in raw_text:
|
||||||
|
failures.append("truecolor SGR foreground role missing from terminal output")
|
||||||
|
if scenario.color_mode == "mono" and scenario.mode_surface != "panel":
|
||||||
|
for text in ("mode:", " 1│"):
|
||||||
|
if text not in plain_text:
|
||||||
|
failures.append(f"mono/degraded transcript lost legible chrome text: {text!r}")
|
||||||
|
if "▌" in plain_text:
|
||||||
|
failures.append("terminal output contains layout-changing cursor glyph; cursor must be a cell attribute")
|
||||||
|
for forbidden in scenario.forbidden_raw:
|
||||||
|
if forbidden in raw_text:
|
||||||
|
failures.append(f"forbidden terminal text present: {forbidden!r}")
|
||||||
|
if scenario.mode_surface != "panel" and "mode:normal" not in raw_text:
|
||||||
|
failures.append("scenario never reached normal mode")
|
||||||
|
if any(len(line) > scenario.width for line in lines):
|
||||||
|
failures.append("terminal line exceeds viewport width")
|
||||||
|
if failures:
|
||||||
|
print(f"artifacts\t{out_dir}", file=sys.stderr)
|
||||||
|
for failure in failures:
|
||||||
|
print(f"FAIL\t{scenario.id}\t{failure}", file=sys.stderr)
|
||||||
|
raise SystemExit(1)
|
||||||
|
return scenario.id, out_dir
|
||||||
|
|
||||||
|
|
||||||
|
def run_matrix(mim: Path, out_root: Path, only: set[str] | None) -> None:
|
||||||
|
self_test()
|
||||||
|
selected = [scenario for scenario in SCENARIOS if only is None or scenario.id in only]
|
||||||
|
unknown = (only or set()) - {scenario.id for scenario in SCENARIOS}
|
||||||
|
if unknown:
|
||||||
|
raise SystemExit(f"unknown scenario id(s): {', '.join(sorted(unknown))}")
|
||||||
|
if out_root.exists():
|
||||||
|
shutil.rmtree(out_root)
|
||||||
|
out_root.mkdir(parents=True, exist_ok=True)
|
||||||
|
rows = ["scenario\tstatus\tartifacts"]
|
||||||
|
matrix_rows = ["scenario\tviewport\tfile_state\tinput_profile\tmode_surface\tcolor_mode\tkey_events\tmax_key_events\tartifacts"]
|
||||||
|
for scenario in selected:
|
||||||
|
scenario_id, artifact_dir = run_one(mim, out_root, scenario)
|
||||||
|
rows.append(f"{scenario_id}\tPASS\t{artifact_dir}")
|
||||||
|
_, key_events, _ = keystroke_rows(scenario)
|
||||||
|
matrix_rows.append(f"{scenario.id}\t{scenario.width}x{scenario.height}\t{scenario.file_state}\t{scenario.input_profile}\t{scenario.mode_surface}\t{scenario.color_mode}\t{key_events}\t{scenario.max_key_events if scenario.max_key_events is not None else ''}\t{artifact_dir}")
|
||||||
|
(out_root / "matrix.tsv").write_text("\n".join(matrix_rows) + "\n", encoding="utf-8")
|
||||||
|
print("\n".join(rows))
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Run mim through a PTY and write browser-viewable terminal E2E artifacts.",
|
||||||
|
epilog=(
|
||||||
|
"Golden policy: investigate before updating artifacts. Semantic snapshots "
|
||||||
|
"(matrix.tsv, manifest.tsv, transcript.txt, visible-controls.normalized.txt) are the "
|
||||||
|
"primary review surface; screenshot.svg/terminal.html are visual evidence. "
|
||||||
|
"It is safe to update artifacts only after explaining intentional UI/control-byte changes."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument("mim", type=Path, help="path to compiled mim binary")
|
||||||
|
parser.add_argument("--out", type=Path, default=Path(".zig-cache/terminal-e2e"))
|
||||||
|
parser.add_argument("--scenario", action="append", help="run only this scenario id; may be repeated")
|
||||||
|
args = parser.parse_args()
|
||||||
|
run_matrix(args.mim.resolve(), args.out, set(args.scenario) if args.scenario else None)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
raise SystemExit(main())
|
||||||
Reference in New Issue
Block a user