Compare commits

..

50 Commits

Author SHA1 Message Date
slhx agent c6c5366098 Expose panel rails with mobile aliases 2026-06-21 22:52:41 +02:00
slhx agent 4789cf79bc Group LSP parameter navigation on language rail 2026-06-21 22:47:29 +02:00
slhx agent accbedbe4e Add select object rail 2026-06-21 22:39:56 +02:00
slhx agent d7bf5edc1f Separate first nonblank line motion 2026-06-21 22:34:51 +02:00
slhx agent d5c31a92c5 Document Esc and Ctrl bracket mode exits 2026-06-21 22:29:20 +02:00
slhx agent 2e60a0961c Define mobile SSH input tiers 2026-06-21 22:23:47 +02:00
slhx agent 6271fac301 Measure styled smoke frames by visible cells 2026-06-21 22:21:19 +02:00
slhx agent f00eddcb2b Add panel hierarchy styling 2026-06-21 22:17:15 +02:00
slhx agent de3b5d436f Detect terminal color capabilities 2026-06-21 22:07:24 +02:00
slhx agent 7f6d37d4a3 Color status panels by source state 2026-06-21 22:01:13 +02:00
slhx agent a9b4eff8a6 Color syntax spans with newed palette 2026-06-21 21:53:54 +02:00
slhx agent 3df3b25839 Apply newed palette to editor chrome 2026-06-21 21:41:51 +02:00
slhx agent 5652845d2e Add newed night theme model 2026-06-21 21:31:20 +02:00
slhx agent 9ef2f97423 Expose file and Pi panels 2026-06-21 21:26:51 +02:00
slhx agent 062d0f1af9 Expand coding symbol rail 2026-06-21 21:20:29 +02:00
slhx agent 27c4269a3d Add normal dot repeat 2026-06-21 21:14:56 +02:00
slhx agent c98c530c44 Add document and half-page motions 2026-06-21 21:03:10 +02:00
slhx agent 2277ed281d Preserve preferred column on vertical motion 2026-06-21 20:25:39 +02:00
slhx agent 9f4c1539e4 Fix select motions and mobile line aliases 2026-06-21 20:15:10 +02:00
slhx agent b83431aefc Clarify redgate requirement governance 2026-06-21 19:53:46 +02:00
slhx agent 70d8582448 Cover job output terminal workflow 2026-06-21 19:46:45 +02:00
slhx agent 19f7354d1c Cover project search terminal workflow 2026-06-21 19:43:43 +02:00
slhx agent bdc9de0d1c Document terminal E2E verification gate 2026-06-21 19:36:41 +02:00
slhx agent a96b9e8142 Save LSP format E2E mutation 2026-06-21 19:34:17 +02:00
slhx agent 38c487f7c3 Add LSP-assisted terminal E2E coverage 2026-06-21 19:27:49 +02:00
slhx agent d6ebf1cc06 Prove multi-file terminal coding loop 2026-06-21 18:59:45 +02:00
slhx agent e486856f79 Add terminal E2E keystroke budgets 2026-06-21 18:39:56 +02:00
slhx agent 61a1f9b83e Assert terminal E2E chrome roles 2026-06-21 18:31:50 +02:00
slhx agent 0d4d0ec425 Expand terminal input E2E coverage 2026-06-21 18:26:02 +02:00
slhx agent 9aafe0e01c Fix terminal input mode and cursor rendering 2026-06-21 18:08:59 +02:00
slhx agent 5d7f239d34 Add terminal E2E matrix runner 2026-06-21 17:36:24 +02:00
slhx agent 86af6d774e Add terminal E2E control snapshots 2026-06-21 17:22:36 +02:00
slhx agent 70e64180c1 Add PTY terminal E2E harness 2026-06-21 17:17:37 +02:00
slhx agent 8c4d09f709 Add visible terminal control debug helpers 2026-06-21 16:41:15 +02:00
slhx agent f88f688726 Fix raw terminal line rendering 2026-06-21 16:37:10 +02:00
slhx agent 629605bcdc Fix local editor raw mode and chrome 2026-06-21 15:22:41 +02:00
slhx agent f75ee1ddb4 Add NS8 local production smoke 2026-06-21 14:34:33 +02:00
slhx agent e96d33edb9 Add physical keyboard parity aliases 2026-06-21 14:30:31 +02:00
slhx agent 616eea670b Add language format and code action edits 2026-06-21 14:21:09 +02:00
slhx agent e4d6837f78 Add first-class job rail profiles 2026-06-21 14:09:53 +02:00
slhx agent 5224fe4c6a Add provider-aware diagnostics rail 2026-06-21 13:56:46 +02:00
slhx agent 264dedb47c Add compact language assistance rails 2026-06-21 13:34:40 +02:00
slhx agent 7e639dd87f Add language provider registry 2026-06-21 13:20:09 +02:00
slhx agent 1d1ecf29d5 Add file and project search panels 2026-06-21 13:11:56 +02:00
slhx agent 70ef989c2a Add current file search 2026-06-21 12:53:17 +02:00
slhx agent 30af1e97bc Add composable object selections 2026-06-21 12:41:43 +02:00
slhx agent a08ee02820 Add core editing commands 2026-06-21 12:31:27 +02:00
slhx agent 1468675792 Add modal input state and rails 2026-06-21 12:18:13 +02:00
slhx agent 047bae94af Launch local editor for paths 2026-06-21 12:03:26 +02:00
slhx agent b076d1a35a Define v1 modal keymap grammar 2026-06-21 10:51:28 +02:00
24 changed files with 7443 additions and 149 deletions
+14 -1
View File
@@ -3,7 +3,7 @@
## Authority
- `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.
## Product constraints
@@ -38,6 +38,19 @@ zig build v1-smoke
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:
```sh
+3 -3
View File
@@ -1,6 +1,6 @@
# 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
@@ -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.
## Receipt to paste into #41
## Receipt to paste into #41 and/or #56
```text
DOGFOOD RECEIPT
@@ -53,4 +53,4 @@ DOGFOOD RECEIPT
## 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.
+442
View File
@@ -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
View File
@@ -2,7 +2,7 @@
## 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
@@ -14,7 +14,7 @@ The user opens Vim or Neovim over SSH and tolerates bindings, ex commands, split
## 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
+24 -2
View File
@@ -12,23 +12,43 @@ Rows are redgate TSV requirements: `ring<TAB>id<TAB>summary [tag]`.
## input
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 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]
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
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]
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
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 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 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
@@ -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]
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 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
+5
View File
@@ -96,6 +96,11 @@ pub fn build(b: *std.Build) void {
const smoke_cmd = b.addRunArtifact(smoke_tests);
const smoke_step = b.step("v1-smoke", "Run cheap v1 end-to-end fixture suite");
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 {
+131
View File
@@ -1,4 +1,5 @@
const std = @import("std");
const lsp = @import("lsp.zig");
// Local v1 guardrails for SSH/iPhone use: visible rows instead of silent stalls.
// 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_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 {
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("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);
}
+30
View File
@@ -18,6 +18,12 @@ pub const Key = enum {
arrow_right,
arrow_up,
arrow_down,
home,
end,
page_up,
page_down,
ctrl_u,
ctrl_d,
};
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[A")) return .{ .key = .arrow_up };
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 };
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[A", "key:arrow_up");
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" {
@@ -272,3 +290,15 @@ test "adversarial: replay names first event mismatch" {
.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
View File
@@ -13,8 +13,144 @@ pub const Error = error{
InvalidCommand,
InvalidJobRow,
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 {
path: []const u8,
line: usize,
+1 -1
View File
@@ -162,7 +162,7 @@ pub fn classifyEvent(profile: *const Profile, event: input.Event) Classified {
const kind: Kind = switch (event) {
.key => |key| switch (key) {
.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,
.unknown => .unknown,
+301 -17
View File
@@ -13,14 +13,45 @@ test {
pub const Feature = enum {
search,
panel_close,
lsp,
diagnostics,
jobs,
};
pub const Action = union(enum) {
none,
save,
quit,
force_quit,
open: []u8,
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,
pub fn deinit(self: Action, allocator: std.mem.Allocator) void {
@@ -35,6 +66,10 @@ const Mode = enum {
idle,
rail,
symbol_rail,
search_rail,
language_rail,
diagnostic_rail,
tool_rail,
open_prompt,
};
@@ -58,6 +93,10 @@ pub const Leader = struct {
.idle => return self.handleIdle(event),
.rail => return self.handleRail(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),
}
}
@@ -66,8 +105,12 @@ pub const Leader = struct {
if (self.message) |message| return message;
return switch (self.mode) {
.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,
.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",
};
}
@@ -84,6 +127,10 @@ pub const Leader = struct {
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 {
self.message = null;
switch (event) {
@@ -100,27 +147,54 @@ pub const Leader = struct {
self.message = null;
switch (event) {
.text => |text| {
if (std.mem.eql(u8, text, "s")) {
if (std.mem.eql(u8, text, "w")) {
self.mode = .idle;
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")) {
self.mode = .idle;
return .quit;
}
if (std.mem.eql(u8, text, "Q")) {
self.mode = .idle;
return .force_quit;
}
if (std.mem.eql(u8, text, "o")) {
self.open_prompt.clearRetainingCapacity();
self.mode = .open_prompt;
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;
return .none;
}
if (std.mem.eql(u8, text, "/")) {
if (std.mem.eql(u8, text, "r")) {
self.mode = .idle;
self.message = "search is not built in this profile yet";
return .{ .not_built = .search };
return .repeat_rail;
}
if (std.mem.eql(u8, text, "x")) {
self.mode = .idle;
@@ -137,7 +211,10 @@ pub const Leader = struct {
self.message = "leader cancelled";
return .none;
},
.space => return .none,
.space => {
self.mode = .idle;
return .restore_last;
},
else => {
self.mode = .idle;
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 {
self.message = null;
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));
}
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);
defer leader.deinit();
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());
}
@@ -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);
defer leader.deinit();
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
const action = try leader.handleEvent(input.normalize("/"));
switch (action) {
.not_built => |feature| try std.testing.expectEqual(Feature.search, feature),
else => return error.ExpectedNotBuiltAction,
}
try std.testing.expectEqualStrings("search is not built in this profile yet", leader.status());
try expectActionTag(.none, try leader.handleEvent(input.normalize("s")));
try std.testing.expectEqualStrings("search: f current file p project s symbols", leader.status());
try expectActionTag(.search_file, try leader.handleEvent(input.normalize("f")));
try expectActionTag(.none, try leader.handleEvent(input.normalize(" ")));
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" {
@@ -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 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
View File
@@ -19,8 +19,299 @@ pub const Error = error{
InvalidEdit,
InvalidEditRow,
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 {
uri: []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.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
View File
@@ -17,6 +17,8 @@ const session = @import("session.zig");
const socket = @import("socket.zig");
const symbol = @import("symbol.zig");
const syntax = @import("syntax.zig");
const terminal_debug = @import("terminal_debug.zig");
const theme = @import("theme.zig");
const tui = @import("tui.zig");
pub const version = "0.1.0-dev";
@@ -25,6 +27,7 @@ const help_text =
\\mim - mobile-first terminal code editor for SSH sessions
\\
\\Usage:
\\ mim [path]
\\ mim [--help]
\\ mim [--version]
\\ mim context [task] [intent]
@@ -32,25 +35,25 @@ const help_text =
\\ mim mimctl context <socket|-> [task] [intent]
\\ mim --mimctl <socket|-> <request...>
\\
\\This early build exposes the canonical smoke path, compact local context,
\\and a minimal local session-control client for trusted tools.
\\With no path, mim opens the current directory. Existing files open in the
\\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) {
help,
version,
smoke,
unknown: []const u8,
open: []const u8,
};
fn parseArgs(args: []const []const u8) Command {
if (args.len <= 1) return .smoke;
if (args.len <= 1) return .{ .open = "." };
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, "--version")) return .version;
return .{ .unknown = arg };
return .{ .open = arg };
}
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 })
else
parseArgs(&.{"mim"});
@@ -133,18 +147,329 @@ pub fn main(init: std.process.Init) !u8 {
switch (command) {
.help => try stdout.writeStreamingAll(init.io, help_text),
.version => try stdout.writeStreamingAll(init.io, versionText()),
.smoke => try stdout.writeStreamingAll(init.io, help_text),
.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;
},
.open => |path| try openLocalEditor(allocator, init.io, stdout, stderr, path, e2e_prelude_path),
}
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 {
const task = args.next();
const intent = args.next();
@@ -245,9 +570,9 @@ test {
_ = 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, "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 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());
}
test "adversarial: unknown arguments are rejected instead of ignored" {
const argv = [_][]const u8{ "mim", "--definitely-not-supported" };
test "regular: no args opens current directory" {
const argv = [_][]const u8{"mim"};
const parsed = parseArgs(&argv);
switch (parsed) {
.unknown => |arg| try std.testing.expectEqualStrings("--definitely-not-supported", arg),
else => return error.ExpectedUnknownArgument,
.open => |path| try std.testing.expectEqualStrings(".", path),
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 parsed = parseArgs(&argv);
switch (parsed) {
.unknown => |arg| try std.testing.expectEqualStrings("some-file.zig", arg),
else => return error.ExpectedUnknownArgument,
.open => |path| try std.testing.expectEqualStrings("some-file.zig", path),
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
View File
@@ -67,51 +67,40 @@ fn containsForbiddenToken(line: []const u8) bool {
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 =
\\open
\\type call
\\open safe
\\type r
\\type x
\\key space
\\key p
\\key p
\\type arg
\\key right
\\key space
\\key p
\\key s
\\key space
\\key s
\\key w
\\key space
\\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 =
\\open safe
\\type é
\\key backspace
\\key space
\\key p
\\key c
\\type x
\\key right
\\key space
\\key s
\\key w
\\key space
\\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" {
const trace =
\\open abc
\\key space
\\key /
\\key s
\\
;
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);
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.expectError(tui.Error.NothingSaved, client.saved());
}
+8
View File
@@ -155,6 +155,14 @@ pub const Stack = struct {
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 {
_ = try self.activeList();
try self.closeActive();
+32
View File
@@ -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 {
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_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_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, "panel_open ")) return panelRespond(allocator, session, .{ .open = command[11..] });
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);
}
}
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
View File
@@ -26,9 +26,25 @@ pub const Pair = struct {
pub const Command = union(enum) {
move_left,
move_right,
move_up,
move_down,
move_word_forward,
move_word_back,
move_word_end,
move_line_first_nonblank,
insert: []const u8,
insert_pair: Pair,
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 {
@@ -46,6 +62,7 @@ pub const Buffer = struct {
bytes: std.ArrayList(u8),
cursor: Cursor = .{},
selection: ?Selection = null,
preferred_vertical_cell: ?usize = null,
pub fn openFromBytes(allocator: std.mem.Allocator, fixture: []const u8) !Buffer {
var bytes = std.ArrayList(u8).empty;
@@ -74,27 +91,103 @@ pub const Buffer = struct {
switch (command) {
.move_left => self.moveLeft(),
.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_pair => |pair| try self.insertPair(pair),
.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 {
self.cursor.byte = previousBoundary(self.bytes.items, self.cursor.byte);
self.refreshCell();
self.refreshCellResetPreferred();
}
pub fn moveRight(self: *Buffer) void {
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 {
if (!std.unicode.utf8ValidateSlice(text)) return error.InvalidUtf8Insertion;
try self.bytes.insertSlice(self.allocator, self.cursor.byte, text);
self.cursor.byte += text.len;
self.refreshCell();
self.refreshCellResetPreferred();
self.selection = null;
}
@@ -104,7 +197,7 @@ pub const Buffer = struct {
defer self.allocator.free(combined);
try self.bytes.insertSlice(self.allocator, self.cursor.byte, combined);
self.cursor.byte += pair.open.len;
self.refreshCell();
self.refreshCellResetPreferred();
self.selection = null;
}
@@ -113,12 +206,119 @@ pub const Buffer = struct {
const start = previousBoundary(self.bytes.items, self.cursor.byte);
self.bytes.replaceRangeAssumeCapacity(start, self.cursor.byte - start, "");
self.cursor.byte = start;
self.refreshCell();
self.refreshCellResetPreferred();
self.selection = null;
}
fn refreshCell(self: *Buffer) void {
self.cursor.cell = cellWidth(self.bytes.items[0..self.cursor.byte]);
pub fn deleteForward(self: *Buffer) void {
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();
var buffer = try Buffer.openFromBytes(self.allocator, fixture);
buffer.cursor.byte = boundaryAtOrBefore(buffer.bytes.items, @min(cursor_byte, buffer.bytes.items.len));
buffer.refreshCell();
buffer.refreshCellResetPreferred();
self.buffer = buffer;
}
@@ -190,6 +390,10 @@ pub const Session = struct {
return self.panels.selectList();
}
pub fn activeListItem(self: *const Session) ![]const u8 {
return self.panels.activeListItem();
}
pub fn cancelListPanel(self: *Session) !void {
try self.panels.cancelList();
}
@@ -225,8 +429,63 @@ pub const Session = struct {
.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 {
var i = @min(cursor, bytes.len);
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();
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
View File
@@ -12,6 +12,12 @@ pub const Symbol = enum {
brackets,
braces,
slash,
line_comment,
star,
ampersand,
hash,
dollar,
at,
pipe,
double_quote,
single_quote,
@@ -31,6 +37,12 @@ pub const entries = [_]Entry{
.{ .key = "b", .symbol = .brackets, .label = "[]", .protocol_command = "command pair brackets" },
.{ .key = "c", .symbol = .braces, .label = "{}", .protocol_command = "command pair braces" },
.{ .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 = "q", .symbol = .double_quote, .label = "\"\"", .protocol_command = "command pair double_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 _" },
};
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 {
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.braces, lookup("c").?.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.double_quote, lookup("q").?.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 braces", protocolCommand(.braces));
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 pair double_quote", protocolCommand(.double_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" {
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(""));
}
+36
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+87
View File
@@ -188,3 +188,90 @@ fn freeRows(rows: [][]const u8) void {
for (rows) |row| allocator.free(row);
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;
}
+76
View File
@@ -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.
+741
View File
@@ -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())