pair-mode 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +46 -79
  2. package/dist/cli.js +474 -111
  3. package/package.json +2 -1
package/README.md CHANGED
@@ -1,8 +1,16 @@
1
1
  # pair mode
2
2
 
3
- A coding agent proposes an edit. Pair mode opens a side-by-side diff in a terminal
4
- editor. Every line you type becomes a question the model must answer, and the edit
5
- does not apply until you close the editor.
3
+ #### For when your agent needs a rubber ducky.
4
+
5
+ `pair-mode` is a tool that allows you to sit somewhere between being an approval monkey for your agent running in manual mode and just totally vibe coding in auto mode.
6
+
7
+ If you've ever found yourself wanting to pay attention to the code that your agent of choice is generating and (god forbid) suggest changes, I'm sure you've run into: "_oh no, I clicked 'reject' and now the proposed diff is gone - what did I want to suggest again?_"
8
+
9
+ `pair-mode` attempts to solve this.
10
+
11
+ When enabled, your agent will push diffs to your editing interface of choice (your choices are currently: in a separate terminal tab/pane, multiplexer sub-pane or web - sorry if your interface of choice is not actually here (yet)). From there you can go line by line over the diff and add comments, suggestions, chidings, deranged ramblings, etc. Your agent will receive your thoughts and proceed accordingly.
12
+
13
+ ![The pair review pane open in a zellij floating pane, with three notes anchored to line numbers](https://raw.githubusercontent.com/oFrusch/pair-mode/main/docs/images/pane-zellij.png)
6
14
 
7
15
  ## Install
8
16
 
@@ -11,55 +19,36 @@ npm install -g pair-mode
11
19
  pair-mode setup
12
20
  ```
13
21
 
14
- Install pair-mode globally before you run setup. Setup writes the install path into each
15
- CLI's config as an absolute path. `npx pair-mode setup` runs from a package cache that
16
- npm later prunes, which would leave every hook pointing at a deleted file. Setup detects
17
- that case and stops.
22
+ Install pair-mode globally before you run setup. Setup writes the install path into each CLI's config as an absolute path. `npx pair-mode setup` runs from a package cache that npm later prunes, which would leave every hook pointing at a deleted file. Setup detects that case and stops.
18
23
 
19
24
  The setup command detects the CLIs and multiplexers on your machine, registers the
20
- required hooks, and writes a config file. Restart Claude Code after setup, because
21
- Claude Code loads hooks only at startup.
22
-
23
- If you install from git instead of npm, run `pnpm build` first. The package does not
24
- ship a committed `dist/` directory; npm builds it fresh through the `prepublishOnly`
25
- script, but a git checkout does not run that script.
25
+ required hooks, and writes a config file.
26
26
 
27
27
  ## Supported CLIs
28
28
 
29
- | CLI | Hook | Status |
30
- | ----------- | ------------------------------------------------ | --------------------------------------------------------------------------------------------- |
31
- | Claude Code | `PreToolUse`, matcher `Write\|Edit\|MultiEdit` | Shipped. |
32
- | Codex | `PreToolUse`, matcher `apply_patch\|Edit\|Write` | Shipped. |
33
- | pi | `tool_call` extension hook | Planned for 1.0.0. The adapter exists and passes its unit tests. Setup does not offer it yet. |
34
- | opencode | `tool.execute.before` plugin hook | Planned for 1.0.0. Nobody has run it against a live opencode. Setup does not offer it yet. |
35
-
36
- Codex has no `MultiEdit` matcher alias. Its `apply_patch` parser reads single-file Add,
37
- Update, and Delete patches only. A multi-file or rename patch passes through untouched.
29
+ | CLI | Hook | Status |
30
+ | ----------- | ------------------------------------------------ | ------------------------------------------- |
31
+ | Claude Code | `PreToolUse`, matcher `Write\|Edit\|MultiEdit` | Should work. |
32
+ | Codex | `PreToolUse`, matcher `apply_patch\|Edit\|Write` | Should work. |
33
+ | pi | `tool_call` extension hook | Will maybe work. Probably not. Coming soon. |
34
+ | opencode | `tool.execute.before` plugin hook | Will maybe work. Probably not. Coming soon. |
38
35
 
39
36
  ## The /pair command
40
37
 
41
- `pair-mode setup` installs a `/pair` command for every CLI whose hook it registers. The
42
- command toggles pair mode for the current directory. It also tells the agent how a held
43
- edit comes back.
38
+ `pair-mode setup` installs a `/pair` (`$pair` for Codex) command for every CLI whose hook it registers. The command toggles pair mode for the current directory. It also tells the agent how a held edit comes back.
44
39
 
45
40
  | CLI | Installed at | Invoked as |
46
41
  | ----------- | ------------------------------- | ----------------------- |
47
42
  | Claude Code | `~/.claude/commands/pair.md` | `/pair on`, `/pair off` |
48
43
  | Codex | `~/.codex/skills/pair/SKILL.md` | `$pair on`, `$pair off` |
49
44
 
50
- Codex deprecated `~/.codex/prompts/` in favour of skills, so pair mode installs a skill
51
- there. Codex invokes a skill with `$`, not `/`.
52
-
53
45
  The command runs a bare `pair-mode`, unlike a hook, which each CLI invokes by absolute
54
46
  path. So `pair-mode` must resolve on your PATH. A global install puts it there.
55
47
 
56
- `pair-mode doctor` warns when the command is missing, and warns when `pair-mode` does not
57
- resolve on PATH. Neither warning raises the exit code.
58
-
59
- Setup backs up an existing file before it rewrites the file.
60
-
61
48
  ## Editors
62
49
 
50
+ This is what will render your diff if you choose to use one of the CLI-based approaches. We ship the `pair` editor, which has good syntax highlight and mouse highlighting support. This makes the annotation process a bit easier. But we also support most popular terminal-based editors.
51
+
63
52
  | Editor | Diff colour | Syntax colour on changed rows |
64
53
  | -------------- | ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
65
54
  | pair (default) | Yes | Yes. pair is the built-in review pane, so it paints syntax colour and change colour on the same row. It also reads mouse clicks and drags for selection, and it anchors a note to the exact span you select, not the whole line. |
@@ -97,28 +86,16 @@ keyboard only.
97
86
  | `Ctrl+q` `Ctrl+c` | — | Quit. With no notes, the edit applies. With notes pending, asks: `s` sends, `d` discards and quits, `Esc` cancels. |
98
87
  | `?` | — | Toggle the keymap overlay. |
99
88
 
100
- zellij consumes the mouse scroll wheel, so pair mode never depends on it. `Ctrl+d` and
101
- `Ctrl+u` page instead.
102
-
103
- Holding `shift` during a click or drag passes the mouse event straight through to the
104
- terminal, which then selects text for copy — the same as any other terminal program.
105
-
106
89
  ## Multiplexers
107
90
 
108
- Claude Code and Codex run their hooks with no controlling terminal. `/dev/tty` returns
109
- `ENXIO` there. Pane mode needs zellij or tmux to open an editor pane under those two
110
- CLIs.
111
-
112
- Session mode removes the requirement. See the next section.
91
+ If you want to do all your development in one terminal pane while using either Claude Code or Codex, you'll need to use a multiplexer (I use zellij but tmux works great as well). Both harnesses run their hooks with no controlling terminal. A multiplexer allows us to open up the agent's diff in a floating sub-pane.
113
92
 
114
93
  ## Session modes
115
94
 
116
- Pane mode is the default. The hook opens an editor in a floating pane and blocks. That
117
- needs a multiplexer under Claude Code and Codex.
118
-
119
95
  Session mode moves the review out of the agent's process. The hook posts to a Unix
120
- socket and waits. A client you start renders the review and answers. There are two
121
- clients, and both remove the multiplexer requirement.
96
+ socket and waits. A client you start (another terminal tab or the web view) renders the review and allows you to provide your annotations.
97
+
98
+ ### Session mode config
122
99
 
123
100
  Set `transport` to `"session"`, then pick a client.
124
101
 
@@ -126,39 +103,41 @@ Set `transport` to `"session"`, then pick a client.
126
103
  pair-mode config transport session
127
104
  ```
128
105
 
129
- `pair-mode config` with no argument prints every setting and its value. `pair-mode
130
- config <key>` prints one. `pair-mode config <key> <value>` changes one and validates it
131
- before it writes.
106
+ - `pair-mode config` with no argument prints every setting and its value.
107
+ - `pair-mode config <key>` prints one.
108
+ - `pair-mode config <key> <value>` changes one and validates it before it writes.
132
109
 
133
110
  **Watch mode** reviews in a terminal you own.
134
111
 
135
112
  ```
136
- pair-mode watch # in any terminal, at the repo root
113
+ pair-mode watch
137
114
  ```
138
115
 
139
- The watcher paints an idle screen between reviews and opens the pair TUI for each one.
116
+ ![Claude Code on the left, the pair review pane on the right in a second terminal pane](https://raw.githubusercontent.com/oFrusch/pair-mode/main/docs/images/watch-terminal.png)
117
+
118
+ Run this command in the directory or any subdirectory of where you are working with your agent. The diffs your agent suggests will be pushed to the running Unix socket and rendered wherever this command was run. You can then mark up the diffs as you please and send them back to your agent.
119
+
140
120
  `q` on the idle screen quits and releases the socket.
141
121
 
142
122
  **Web mode** reviews in a browser and needs no terminal at all.
143
123
 
144
124
  ```
145
- pair-mode on --web # prints a link
125
+ pair-mode on --web
146
126
  ```
147
127
 
148
- That spawns a detached watcher, binds an HTTP server on `127.0.0.1`, and prints a link
149
- carrying a random token. Drag across any text in the diff. A popup opens under the
150
- selection. Type the note and press Enter. `pair-mode off` stops the watcher.
128
+ ![The inline web layout, with two notes in the right margin joined to their lines by a leader](https://raw.githubusercontent.com/oFrusch/pair-mode/main/docs/images/web-inline.png)
151
129
 
152
- The watcher process binds the socket, so there is no separate daemon to manage. If the
153
- watcher dies, the socket goes away and every hook fails open at once.
130
+ That spawns a detached watcher, binds an HTTP server on `127.0.0.1`, and prints a link carrying a random token. Drag across any text in the diff. A popup opens under the selection. Type the note and press Enter.
154
131
 
155
- A hook that finds no watcher applies the edit rather than hanging. So does a hook whose
156
- watcher never answers within `session.timeout`.
132
+ The page opens in the layout your `layout` setting names, and `u` swaps it while you read. The inline layout puts every note in the right margin and draws a leader from the marked run to the card. The split layout threads each note under the last line it covers, and it hatches the side of a row that has no line.
133
+
134
+ ![The split web layout, with a note threaded under the line it covers](https://raw.githubusercontent.com/oFrusch/pair-mode/main/docs/images/web-split.png)
157
135
 
158
136
  ## Configuration
159
137
 
160
- Pair mode reads `$XDG_CONFIG_HOME/pair-mode/config.json`, or `~/.config/pair-mode/config.json`
161
- when `XDG_CONFIG_HOME` is not set.
138
+ Run `pair-mode setup` when first installing and that should get you most of the way there in terms of pair mode working for your setup. Regardless, all configuration options and their accepted values are listed below.
139
+
140
+ Pair mode reads `$XDG_CONFIG_HOME/pair-mode/config.json`, or `~/.config/pair-mode/config.json` when `XDG_CONFIG_HOME` is not set.
162
141
 
163
142
  | Key | Type | Default | Meaning |
164
143
  | ----------------- | --------------------------------------------------------------------------------------- | ----------- | ---------------------------------------------------------------- |
@@ -188,21 +167,9 @@ existing config with no `editor` key now opens pair mode's own pane by default.
188
167
 
189
168
  `notes` and `layout` both describe position, and they are easy to confuse. `layout`
190
169
  controls the diff itself — split into two columns, or one inline column. `notes`
191
- controls only where a note you write renders — in a docked panel, or anchored next to
192
- the span it annotates. Setting one does not affect the other.
193
-
194
- ## Limits
195
-
196
- - Pair mode opens one pane per tool call. It cannot batch a changeset, because a hook
197
- returns one verdict per call and an allow cannot be withdrawn.
198
- - The pane reviews. It does not edit the proposal.
199
- - Syntax colour in pair needs `shiki` installed. A missing package disables colour and
200
- the pane still works.
201
- - A client answers one review at a time. It cannot skip ahead in the queue.
202
- - Web mode does not defend against another user on the same machine. The token sits in
203
- the URL, and a local user can read the process list.
204
- - Watch mode needs the watcher running before the agent edits. A missing watcher fails
205
- open, so an unattended agent applies its edits.
170
+ controls only where a note you write renders in the pair pane — in a docked panel, or
171
+ anchored next to the span it annotates. Setting one does not affect the other. The web
172
+ view anchors every note, so it reads `layout` and ignores `notes`.
206
173
 
207
174
  ## License
208
175
 
package/dist/cli.js CHANGED
@@ -12626,10 +12626,11 @@ var PairClient = (() => {
12626
12626
  draftRange: () => draftRange,
12627
12627
  escapeHtml: () => escapeHtml,
12628
12628
  hiddenRows: () => hiddenRows,
12629
+ inlineHtml: () => inlineHtml,
12629
12630
  labelOf: () => labelOf,
12631
+ marginNotesHtml: () => marginNotesHtml,
12630
12632
  markRange: () => markRange,
12631
12633
  marksFor: () => marksFor,
12632
- notesHtml: () => notesHtml,
12633
12634
  numberOf: () => numberOf,
12634
12635
  paintCell: () => paintCell,
12635
12636
  quoteOf: () => quoteOf,
@@ -12661,6 +12662,9 @@ var PairClient = (() => {
12661
12662
  function tokensOf(row, pane) {
12662
12663
  return pane === "right" ? row.rightTokens : row.leftTokens;
12663
12664
  }
12665
+ function other(pane) {
12666
+ return pane === "right" ? "left" : "right";
12667
+ }
12664
12668
  function numberOf(row, pane) {
12665
12669
  return pane === "right" ? row.rightNumber : row.leftNumber;
12666
12670
  }
@@ -12719,25 +12723,36 @@ var PairClient = (() => {
12719
12723
  });
12720
12724
  return hidden;
12721
12725
  }
12726
+ function signOf(kind, pane) {
12727
+ if (pane === "left") {
12728
+ return kind === "del" || kind === "replace" ? "-" : " ";
12729
+ }
12730
+ return kind === "add" || kind === "replace" ? "+" : " ";
12731
+ }
12722
12732
  function cellHtml(notes, row, rowIndex, pane) {
12723
12733
  const text = textOf(row, pane);
12724
12734
  const body = paintCell(text, tokensOf(row, pane), marksFor(notes, rowIndex, pane, text.length));
12725
- return '<td class="num ' + pane + '">' + (numberOf(row, pane) ?? "") + '</td><td class="bar ' + pane + '"></td><td class="code ' + pane + '" data-row="' + rowIndex + '" data-pane="' + pane + '">' + body + "</td>";
12735
+ const number = numberOf(row, pane);
12736
+ const void_ = number === null ? " void" : "";
12737
+ return '<td class="num ' + pane + void_ + '">' + (number ?? "") + '</td><td class="sign ' + pane + void_ + '">' + signOf(row.kind, pane) + '</td><td class="code ' + pane + void_ + '" data-row="' + rowIndex + '" data-pane="' + pane + '">' + body + "</td>";
12726
12738
  }
12727
12739
  function foldsByStart(review) {
12728
12740
  return new Map(review.folds.map((fold, foldIndex) => [fold.start, foldIndex]));
12729
12741
  }
12742
+ var SPLIT_COLUMNS = 6;
12743
+ var INLINE_COLUMNS = 4;
12744
+ function foldHtml(foldIndex, count, columns) {
12745
+ return '<tr class="fold" data-fold="' + foldIndex + '"><td colspan="' + columns + '">' + count + " unchanged lines</td></tr>";
12746
+ }
12730
12747
  function tableHtml(review, notes, expanded) {
12731
12748
  const hidden = hiddenRows(review, expanded);
12732
12749
  const byStart = foldsByStart(review);
12733
- const parts = ["<table>"];
12750
+ const parts = ['<table class="split">'];
12734
12751
  review.rows.forEach((row, index) => {
12735
12752
  const foldIndex = byStart.get(index);
12736
12753
  const fold = foldIndex === void 0 ? void 0 : review.folds[foldIndex];
12737
12754
  if (foldIndex !== void 0 && fold !== void 0 && !expanded.has(foldIndex)) {
12738
- parts.push(
12739
- '<tr class="fold" data-fold="' + foldIndex + '"><td colspan="6">' + fold.count + " unchanged lines</td></tr>"
12740
- );
12755
+ parts.push(foldHtml(foldIndex, fold.count, SPLIT_COLUMNS));
12741
12756
  }
12742
12757
  if (hidden.has(index)) {
12743
12758
  return;
@@ -12745,6 +12760,77 @@ var PairClient = (() => {
12745
12760
  parts.push(
12746
12761
  '<tr class="' + escapeHtml(row.kind) + '">' + cellHtml(notes, row, index, "left") + cellHtml(notes, row, index, "right") + "</tr>"
12747
12762
  );
12763
+ parts.push(threadsAt(review, notes, index, SPLIT_COLUMNS));
12764
+ });
12765
+ parts.push("</table>");
12766
+ return parts.join("");
12767
+ }
12768
+ function inlineLines(kind) {
12769
+ if (kind === "del") {
12770
+ return [{ pane: "left", sign: "-", kind: "del" }];
12771
+ }
12772
+ if (kind === "add") {
12773
+ return [{ pane: "right", sign: "+", kind: "add" }];
12774
+ }
12775
+ if (kind === "replace") {
12776
+ return [
12777
+ { pane: "left", sign: "-", kind: "del" },
12778
+ { pane: "right", sign: "+", kind: "add" }
12779
+ ];
12780
+ }
12781
+ return [{ pane: "right", sign: " ", kind: "context" }];
12782
+ }
12783
+ function inlineMarks(notes, rowIndex, kind, pane, length) {
12784
+ if (kind !== "context") {
12785
+ return marksFor(notes, rowIndex, pane, length);
12786
+ }
12787
+ return [
12788
+ ...marksFor(notes, rowIndex, "left", length),
12789
+ ...marksFor(notes, rowIndex, "right", length)
12790
+ ];
12791
+ }
12792
+ function anchorsHere(line, panes, notePane) {
12793
+ return line.pane === notePane || !panes.includes(notePane);
12794
+ }
12795
+ function anchorsAt(notes, rowIndex, line, panes) {
12796
+ return notes.map((note, index) => ({ note, index })).filter(
12797
+ (entry) => entry.note.startRow === rowIndex && anchorsHere(line, panes, entry.note.pane)
12798
+ ).map((entry) => entry.index);
12799
+ }
12800
+ function inlineRowHtml(review, notes, rowIndex, line, panes) {
12801
+ const row = review.rows[rowIndex];
12802
+ if (row === void 0) {
12803
+ return "";
12804
+ }
12805
+ const text = textOf(row, line.pane);
12806
+ const body = paintCell(
12807
+ text,
12808
+ tokensOf(row, line.pane),
12809
+ inlineMarks(notes, rowIndex, row.kind, line.pane, text.length)
12810
+ );
12811
+ const anchors = anchorsAt(notes, rowIndex, line, panes);
12812
+ const leftNumber = line.pane === "left" ? row.leftNumber ?? "" : "";
12813
+ const rightNumber = line.pane === "right" ? row.rightNumber ?? "" : "";
12814
+ return '<tr class="' + line.kind + '"' + (anchors.length === 0 ? "" : ' data-anchor="' + anchors.join(" ") + '"') + '><td class="num old">' + leftNumber + '</td><td class="num new">' + rightNumber + '</td><td class="sign ' + line.pane + '">' + line.sign + '</td><td class="code ' + line.pane + '" data-row="' + rowIndex + '" data-pane="' + line.pane + '">' + body + "</td></tr>";
12815
+ }
12816
+ function inlineHtml(review, notes, expanded) {
12817
+ const hidden = hiddenRows(review, expanded);
12818
+ const byStart = foldsByStart(review);
12819
+ const parts = ['<table class="inline">'];
12820
+ review.rows.forEach((row, index) => {
12821
+ const foldIndex = byStart.get(index);
12822
+ const fold = foldIndex === void 0 ? void 0 : review.folds[foldIndex];
12823
+ if (foldIndex !== void 0 && fold !== void 0 && !expanded.has(foldIndex)) {
12824
+ parts.push(foldHtml(foldIndex, fold.count, INLINE_COLUMNS));
12825
+ }
12826
+ if (hidden.has(index)) {
12827
+ return;
12828
+ }
12829
+ const lines = inlineLines(row.kind);
12830
+ const panes = lines.map((line) => line.pane);
12831
+ lines.forEach((line) => {
12832
+ parts.push(inlineRowHtml(review, notes, index, line, panes));
12833
+ });
12748
12834
  });
12749
12835
  parts.push("</table>");
12750
12836
  return parts.join("");
@@ -12764,13 +12850,21 @@ var PairClient = (() => {
12764
12850
  if (startRow === void 0 || endRow === void 0) {
12765
12851
  return "";
12766
12852
  }
12767
- const start = numberOf(startRow, note.pane);
12853
+ const start = numberOf(startRow, note.pane) ?? numberOf(startRow, other(note.pane));
12768
12854
  const end = numberOf(endRow, note.pane);
12855
+ if (start === null) {
12856
+ return "";
12857
+ }
12769
12858
  return start === end || end === null ? "L" + start : "L" + start + "-" + end;
12770
12859
  }
12771
- function notesHtml(review, notes) {
12860
+ function threadsAt(review, notes, rowIndex, columns) {
12861
+ return notes.map((note, index) => ({ note, index })).filter((entry) => entry.note.endRow === rowIndex).map(
12862
+ (entry) => '<tr class="thread"><td colspan="' + columns + '"><button class="drop" data-drop="' + entry.index + '" aria-label="Remove note">&times;</button><div class="at">' + escapeHtml(labelOf(review, entry.note)) + "</div><p>" + escapeHtml(entry.note.text) + "</p></td></tr>"
12863
+ ).join("");
12864
+ }
12865
+ function marginNotesHtml(review, notes) {
12772
12866
  return notes.map(
12773
- (note, index) => '<div class="note"><button class="drop" data-drop="' + index + '">&times;</button><div class="where">' + escapeHtml(labelOf(review, note)) + '</div><div class="quote">' + escapeHtml(quoteOf(review, note)) + '</div><div class="body">' + escapeHtml(note.text) + "</div></div>"
12867
+ (note, index) => '<div class="note" data-card="' + index + '"><button class="drop" data-drop="' + index + '" aria-label="Remove note">&times;</button><div class="at">' + escapeHtml(labelOf(review, note)) + "</div><p>" + escapeHtml(note.text) + "</p></div>"
12774
12868
  ).join("");
12775
12869
  }
12776
12870
  function sortedNotes(notes) {
@@ -12810,157 +12904,237 @@ var PairClient = (() => {
12810
12904
  var STYLE = `
12811
12905
  :root {
12812
12906
  --bg: #0d1117;
12813
- --panel: #12171f;
12814
- --line: #21262d;
12907
+ --chrome: #10141a;
12908
+ --rule: #1e242c;
12815
12909
  --text: #c9d1d9;
12816
12910
  --muted: ${theme.fold};
12817
- --chrome: ${theme.chrome};
12911
+ --dim: #39424d;
12818
12912
  --add-bar: ${theme.addBar};
12819
12913
  --del-bar: ${theme.delBar};
12820
- --add-tint: rgba(63, 185, 80, 0.09);
12821
- --del-tint: rgba(248, 81, 73, 0.09);
12914
+ --add-tint: rgba(63, 185, 80, 0.08);
12915
+ --del-tint: rgba(248, 81, 73, 0.08);
12822
12916
  --note: ${theme.note};
12823
- --note-tint: rgba(210, 168, 255, 0.22);
12917
+ --note-tint: rgba(210, 168, 255, 0.16);
12918
+ --amber: ${theme.chrome};
12919
+ --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
12824
12920
  }
12825
12921
 
12826
12922
  * { box-sizing: border-box; }
12827
12923
 
12828
12924
  body {
12829
12925
  margin: 0;
12926
+ min-height: 100vh;
12927
+ display: flex;
12928
+ flex-direction: column;
12830
12929
  background: var(--bg);
12831
12930
  color: var(--text);
12832
- font: 13px/1.6 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
12931
+ font: 13px/1.6 var(--mono);
12932
+ -webkit-font-smoothing: antialiased;
12833
12933
  }
12834
12934
 
12935
+ /* ---- chrome ---- */
12936
+
12835
12937
  header {
12836
12938
  position: sticky;
12837
12939
  top: 0;
12838
- z-index: 3;
12940
+ z-index: 6;
12839
12941
  display: flex;
12840
- align-items: center;
12841
- gap: 12px;
12842
- padding: 9px 14px;
12942
+ align-items: stretch;
12843
12943
  background: var(--chrome);
12844
- color: #1f1a12;
12845
- font-weight: 600;
12944
+ border-bottom: 1px solid var(--rule);
12945
+ font-size: 12px;
12846
12946
  }
12847
12947
 
12848
- header .path { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
12948
+ header .seg { padding: 6px 13px; white-space: nowrap; }
12949
+ header .mode { background: var(--amber); color: ${theme.statusText}; font-weight: 700; letter-spacing: 0.1em; }
12950
+ header .path { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; color: var(--muted); }
12951
+ header .plus { color: var(--add-bar); }
12952
+ header .minus { color: var(--del-bar); }
12953
+ header .tally { color: var(--note); }
12849
12954
 
12850
- button {
12851
- font: inherit;
12852
- padding: 4px 12px;
12853
- border: 1px solid rgba(0, 0, 0, 0.35);
12854
- border-radius: 5px;
12855
- background: #1f1a12;
12856
- color: #f0e6d2;
12955
+ footer {
12956
+ position: sticky;
12957
+ bottom: 0;
12958
+ z-index: 6;
12959
+ display: flex;
12960
+ align-items: stretch;
12961
+ background: var(--chrome);
12962
+ border-top: 1px solid var(--rule);
12963
+ font-size: 11.5px;
12964
+ }
12965
+
12966
+ footer .seg { padding: 6px 12px; color: var(--muted); white-space: nowrap; }
12967
+ footer .seg .key { color: var(--text); font-weight: 700; }
12968
+ footer .fill { flex: 1; }
12969
+
12970
+ footer .swap { display: flex; }
12971
+
12972
+ footer .swap button {
12973
+ font: 400 11.5px/1.6 var(--mono);
12974
+ padding: 6px 11px;
12975
+ border: 0;
12976
+ background: none;
12977
+ color: var(--muted);
12978
+ cursor: pointer;
12979
+ }
12980
+
12981
+ footer .swap button:hover { color: var(--text); }
12982
+ footer .swap button[aria-pressed="true"] { background: var(--note-tint); color: var(--note); font-weight: 700; }
12983
+
12984
+ footer .act {
12985
+ font: 700 11.5px/1.6 var(--mono);
12986
+ padding: 6px 14px;
12987
+ border: 0;
12857
12988
  cursor: pointer;
12858
12989
  }
12859
12990
 
12860
- button:disabled { opacity: 0.4; cursor: default; }
12991
+ footer .act:disabled { opacity: 0.35; cursor: default; }
12992
+ footer .act.ok { background: rgba(63, 185, 80, 0.16); color: var(--add-bar); }
12993
+ footer .act.ok:enabled:hover { background: var(--add-bar); color: #06140a; }
12994
+ footer .act.send { background: var(--note); color: #170f1e; }
12995
+ footer .act.send:enabled:hover { filter: brightness(1.12); }
12996
+
12997
+ button:focus-visible, [tabindex]:focus-visible { outline: 2px solid var(--note); outline-offset: -2px; }
12998
+
12999
+ /* ---- diff, both layouts ---- */
12861
13000
 
12862
- main { display: flex; align-items: flex-start; }
13001
+ main { position: relative; flex: 1; display: flex; align-items: flex-start; }
12863
13002
 
12864
13003
  #diff { flex: 1; min-width: 0; overflow-x: auto; }
12865
13004
 
12866
13005
  table { width: 100%; border-collapse: collapse; }
12867
13006
 
12868
- td { padding: 0 8px; vertical-align: top; white-space: pre; }
13007
+ td { padding: 0; vertical-align: top; white-space: pre; }
12869
13008
 
12870
13009
  td.num {
12871
13010
  width: 1%;
12872
- padding: 0 6px;
13011
+ padding: 0 8px;
12873
13012
  text-align: right;
12874
- color: var(--muted);
13013
+ color: var(--dim);
13014
+ font-variant-numeric: tabular-nums;
12875
13015
  user-select: none;
12876
13016
  }
12877
13017
 
12878
- td.bar { width: 3px; padding: 0; }
13018
+ td.sign { width: 1%; padding: 0 6px 0 0; text-align: center; color: var(--dim); user-select: none; }
13019
+ td.code { cursor: text; padding-right: 18px; }
12879
13020
 
12880
- tr.add td.bar.right, tr.replace td.bar.right { background: var(--add-bar); }
12881
- tr.del td.bar.left, tr.replace td.bar.left { background: var(--del-bar); }
13021
+ table.split td.code { width: 50%; }
13022
+ table.inline td.code { width: auto; }
12882
13023
 
12883
- tr.add td.code.right, tr.replace td.code.right { background: var(--add-tint); }
12884
- tr.del td.code.left, tr.replace td.code.left { background: var(--del-tint); }
13024
+ tr.add td.sign.right, tr.replace td.sign.right { color: var(--add-bar); }
13025
+ tr.del td.sign.left, tr.replace td.sign.left { color: var(--del-bar); }
13026
+ tr.add td.sign, tr.add td.code { background: var(--add-tint); }
13027
+ tr.del td.sign, tr.del td.code { background: var(--del-tint); }
13028
+ tr.replace td.sign.right, tr.replace td.code.right { background: var(--add-tint); }
13029
+ tr.replace td.sign.left, tr.replace td.code.left { background: var(--del-tint); }
12885
13030
 
12886
- td.code { width: 50%; cursor: text; }
13031
+ tr.add td.void, tr.del td.void, tr.replace td.void {
13032
+ background: repeating-linear-gradient(-45deg, transparent 0 5px, rgba(255, 255, 255, 0.022) 5px 10px);
13033
+ cursor: default;
13034
+ }
12887
13035
 
12888
13036
  ::selection { background: rgba(88, 166, 255, 0.35); }
12889
13037
 
12890
13038
  mark.noted {
12891
13039
  background: var(--note-tint);
12892
- border-bottom: 2px solid var(--note);
12893
13040
  color: inherit;
12894
- border-radius: 2px;
13041
+ box-shadow: inset 0 -2px 0 var(--note);
12895
13042
  }
12896
13043
 
12897
13044
  tr.fold td {
12898
- padding: 2px 8px;
12899
- color: var(--muted);
12900
- background: var(--panel);
13045
+ padding: 3px 0 3px 18px;
13046
+ color: var(--dim);
13047
+ background: rgba(255, 255, 255, 0.016);
13048
+ border-top: 1px solid var(--rule);
13049
+ border-bottom: 1px solid var(--rule);
12901
13050
  cursor: pointer;
12902
- text-align: center;
12903
13051
  user-select: none;
12904
13052
  }
12905
13053
 
12906
13054
  tr.fold td:hover { color: var(--text); }
12907
13055
 
12908
- aside {
12909
- position: sticky;
12910
- top: 40px;
12911
- width: 300px;
12912
- flex: none;
12913
- max-height: calc(100vh - 40px);
12914
- overflow-y: auto;
12915
- padding: 12px;
12916
- border-left: 1px solid var(--line);
12917
- background: var(--panel);
13056
+ /* ---- notes threaded under a split row ---- */
13057
+
13058
+ tr.thread td {
13059
+ padding: 9px 22px 10px 60px;
13060
+ border-left: 2px solid var(--note);
13061
+ background: rgba(210, 168, 255, 0.05);
13062
+ white-space: normal;
12918
13063
  }
12919
13064
 
12920
- aside h2 {
12921
- margin: 0 0 10px;
12922
- font-size: 11px;
12923
- letter-spacing: 0.08em;
12924
- color: var(--muted);
13065
+ tr.thread .at {
13066
+ font-family: var(--mono);
13067
+ font-size: 10.5px;
13068
+ letter-spacing: 0.11em;
12925
13069
  text-transform: uppercase;
13070
+ color: var(--note);
13071
+ }
13072
+
13073
+ tr.thread p, .note p {
13074
+ margin: 3px 0 0;
13075
+ max-width: 62ch;
13076
+ font-size: 13px;
13077
+ line-height: 1.5;
13078
+ color: #e7e2ea;
12926
13079
  }
12927
13080
 
13081
+ /* ---- notes in the inline margin ---- */
13082
+
13083
+ #margin {
13084
+ position: relative;
13085
+ width: 320px;
13086
+ flex: none;
13087
+ align-self: stretch;
13088
+ padding: 0 20px 0 4px;
13089
+ }
13090
+
13091
+ #margin.hidden { display: none; }
13092
+
12928
13093
  .note {
12929
- margin-bottom: 10px;
12930
- padding: 8px 10px;
12931
- border-left: 3px solid var(--note);
12932
- border-radius: 0 4px 4px 0;
12933
- background: var(--bg);
13094
+ position: absolute;
13095
+ left: 4px;
13096
+ right: 20px;
13097
+ padding-left: 14px;
13098
+ border-left: 1px solid var(--note);
13099
+ }
13100
+
13101
+ .note .at {
13102
+ font-size: 10.5px;
13103
+ letter-spacing: 0.11em;
13104
+ text-transform: uppercase;
13105
+ color: var(--note);
12934
13106
  }
12935
13107
 
12936
- .note .where { color: var(--note); font-size: 11px; }
12937
- .note .quote { margin: 3px 0; color: var(--muted); font-size: 11px; }
12938
- .note .body { white-space: pre-wrap; }
13108
+ #leaders { position: absolute; inset: 0; pointer-events: none; z-index: 1; }
12939
13109
 
12940
- .note .drop {
13110
+ .drop {
12941
13111
  float: right;
12942
- padding: 0 6px;
13112
+ padding: 0 4px;
12943
13113
  border: 0;
12944
13114
  background: none;
12945
- color: var(--muted);
13115
+ color: var(--dim);
13116
+ font: 400 14px/1 var(--mono);
13117
+ cursor: pointer;
12946
13118
  }
12947
13119
 
12948
- .note .drop:hover { color: var(--del-bar); }
13120
+ .drop:hover { color: var(--del-bar); }
13121
+
13122
+ /* ---- popup ---- */
12949
13123
 
12950
13124
  #popup {
12951
13125
  position: absolute;
12952
- z-index: 5;
13126
+ z-index: 8;
12953
13127
  display: none;
12954
- width: 300px;
12955
- padding: 10px;
13128
+ width: 320px;
13129
+ padding: 11px;
12956
13130
  border: 1px solid var(--note);
12957
- border-radius: 6px;
12958
- background: var(--panel);
12959
- box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5);
13131
+ border-radius: 5px;
13132
+ background: var(--chrome);
13133
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.6);
12960
13134
  }
12961
13135
 
12962
13136
  #popup .quote {
12963
- margin-bottom: 6px;
13137
+ margin-bottom: 7px;
12964
13138
  overflow: hidden;
12965
13139
  color: var(--muted);
12966
13140
  font-size: 11px;
@@ -12970,27 +13144,48 @@ aside h2 {
12970
13144
 
12971
13145
  #popup .row { display: flex; gap: 8px; margin-top: 8px; }
12972
13146
 
13147
+ #popup button {
13148
+ font: 500 12px/1 var(--mono);
13149
+ padding: 6px 12px;
13150
+ border: 1px solid var(--rule);
13151
+ border-radius: 4px;
13152
+ background: none;
13153
+ color: var(--text);
13154
+ cursor: pointer;
13155
+ }
13156
+
13157
+ #popup button.save { background: var(--note); border-color: var(--note); color: #170f1e; font-weight: 700; }
13158
+
12973
13159
  textarea {
12974
13160
  width: 100%;
12975
- min-height: 62px;
12976
- padding: 6px;
12977
- border: 1px solid var(--line);
13161
+ min-height: 66px;
13162
+ padding: 7px;
13163
+ border: 1px solid var(--rule);
12978
13164
  border-radius: 4px;
12979
13165
  background: var(--bg);
12980
13166
  color: var(--text);
12981
- font: inherit;
13167
+ font: 13px/1.5 var(--mono);
12982
13168
  resize: vertical;
12983
13169
  }
12984
13170
 
12985
- .hint { color: var(--muted); font-size: 11px; }
13171
+ .hint { color: var(--dim); font-size: 11px; }
12986
13172
 
12987
- #idle { padding: 48px; color: var(--muted); text-align: center; }
13173
+ #idle { padding: 64px 24px; color: var(--dim); text-align: center; }
13174
+
13175
+ @media (max-width: 900px) {
13176
+ main { flex-wrap: wrap; }
13177
+ #leaders { display: none; }
13178
+ #margin { width: 100%; padding: 12px 16px; border-top: 1px solid var(--rule); }
13179
+ .note { position: static; margin-bottom: 12px; }
13180
+ }
12988
13181
  `;
12989
13182
  var WIRING = String.raw`
12990
13183
  const C = PairClient;
12991
13184
  const token = location.pathname.split("/")[2];
13185
+ const main = document.querySelector("main");
12992
13186
  const diff = document.getElementById("diff");
12993
- const notesPanel = document.getElementById("notes");
13187
+ const margin = document.getElementById("margin");
13188
+ const leaders = document.getElementById("leaders");
12994
13189
  const popup = document.getElementById("popup");
12995
13190
  const popupQuote = document.getElementById("popup-quote");
12996
13191
  const popupText = document.getElementById("popup-text");
@@ -12999,37 +13194,151 @@ const popupCancel = document.getElementById("popup-cancel");
12999
13194
  const sendButton = document.getElementById("send");
13000
13195
  const approveButton = document.getElementById("approve");
13001
13196
  const pathLabel = document.getElementById("path");
13197
+ const tallyLabel = document.getElementById("tally");
13198
+ const plusLabel = document.getElementById("plus");
13199
+ const minusLabel = document.getElementById("minus");
13002
13200
 
13003
13201
  let review = null;
13004
13202
  let notes = [];
13005
13203
  let expanded = new Set();
13006
13204
  let draft = null;
13205
+ let layout = document.body.dataset.layout === "inline" ? "inline" : "split";
13206
+
13207
+ function isInline() {
13208
+ return layout === "inline";
13209
+ }
13210
+
13211
+ function countKinds() {
13212
+ let plus = 0;
13213
+ let minus = 0;
13214
+
13215
+ review.rows.forEach((row) => {
13216
+ if (row.kind === "add" || row.kind === "replace") { plus += 1; }
13217
+ if (row.kind === "del" || row.kind === "replace") { minus += 1; }
13218
+ });
13007
13219
 
13008
- function renderNotes() {
13009
- if (review === null || notes.length === 0) {
13010
- notesPanel.innerHTML = '<div class="hint">Select any text in the diff to leave a note.</div>';
13220
+ return { plus, minus };
13221
+ }
13222
+
13223
+ // A leader line joins a marked run to its card, so it redraws whenever either one moves.
13224
+ function drawLeaders() {
13225
+ if (!isInline() || review === null || notes.length === 0) {
13226
+ leaders.innerHTML = "";
13011
13227
  return;
13012
13228
  }
13013
13229
 
13014
- notesPanel.innerHTML = C.notesHtml(review, notes);
13230
+ const base = main.getBoundingClientRect();
13231
+
13232
+ leaders.setAttribute("viewBox", "0 0 " + base.width + " " + base.height);
13233
+ leaders.setAttribute("width", base.width);
13234
+ leaders.setAttribute("height", base.height);
13235
+
13236
+ leaders.innerHTML = notes.map((_note, index) => {
13237
+ const row = diff.querySelector('[data-anchor~="' + index + '"]');
13238
+ const card = margin.querySelector('[data-card="' + index + '"]');
13239
+
13240
+ if (row === null || card === null) {
13241
+ return "";
13242
+ }
13243
+
13244
+ // The line leaves from under the marked run, so it continues that underline and crosses no glyph.
13245
+ const marks = row.querySelectorAll("mark.noted");
13246
+ const ordinal = (row.dataset.anchor ?? "").split(" ").indexOf(String(index));
13247
+ const source = marks[ordinal] ?? marks[marks.length - 1] ?? row;
13248
+ const a = source.getBoundingClientRect();
13249
+ const b = card.getBoundingClientRect();
13250
+ const x1 = a.right - base.left + 5;
13251
+ const y1 = a.bottom - base.top - 0.5;
13252
+ const x2 = b.left - base.left;
13253
+ const y2 = b.top - base.top;
13254
+ const mid = x1 + (x2 - x1) * 0.55;
13255
+
13256
+ return '<path d="M ' + x1 + ' ' + y1 + ' C ' + mid + ' ' + y1 + ', ' + mid + ' ' + y2 + ', ' + x2 + ' ' + y2 +
13257
+ '" fill="none" stroke="var(--note)" stroke-width="1" stroke-opacity="0.5" />' +
13258
+ '<circle cx="' + x1 + '" cy="' + y1 + '" r="2" fill="var(--note)" fill-opacity="0.9" />';
13259
+ }).join("");
13260
+ }
13261
+
13262
+ // Two notes on nearby lines would overlap, so each card starts below the one before it.
13263
+ function placeCards() {
13264
+ if (!isInline()) {
13265
+ return;
13266
+ }
13267
+
13268
+ const base = margin.getBoundingClientRect();
13269
+ let floor = 0;
13270
+
13271
+ notes.forEach((_note, index) => {
13272
+ const row = diff.querySelector('[data-anchor~="' + index + '"]');
13273
+ const card = margin.querySelector('[data-card="' + index + '"]');
13274
+
13275
+ if (row === null || card === null) {
13276
+ return;
13277
+ }
13278
+
13279
+ const top = Math.max(row.getBoundingClientRect().bottom - base.top, floor);
13280
+ card.style.top = top + "px";
13281
+ floor = top + card.offsetHeight + 18;
13282
+ });
13283
+ }
13284
+
13285
+ function renderMargin() {
13286
+ const wanted = isInline() && review !== null && notes.length > 0;
13287
+
13288
+ margin.classList.toggle("hidden", !wanted);
13289
+ margin.innerHTML = wanted ? C.marginNotesHtml(review, notes) : "";
13015
13290
  }
13016
13291
 
13017
13292
  function render() {
13018
13293
  if (review === null) {
13019
13294
  diff.innerHTML = '<div id="idle">waiting for an edit</div>';
13020
13295
  pathLabel.textContent = "no review open";
13296
+ plusLabel.textContent = "";
13297
+ minusLabel.textContent = "";
13298
+ tallyLabel.textContent = "";
13021
13299
  sendButton.disabled = true;
13022
13300
  approveButton.disabled = true;
13023
13301
  hidePopup();
13024
- renderNotes();
13302
+ renderMargin();
13303
+ refreshLeaders();
13025
13304
  return;
13026
13305
  }
13027
13306
 
13307
+ const counts = countKinds();
13308
+
13028
13309
  pathLabel.textContent = review.path;
13310
+ plusLabel.textContent = "+" + counts.plus;
13311
+ minusLabel.textContent = "−" + counts.minus;
13312
+ tallyLabel.textContent = notes.length === 0 ? "" : notes.length + (notes.length === 1 ? " note" : " notes");
13029
13313
  sendButton.disabled = notes.length === 0;
13314
+ sendButton.textContent = "^S send" + (notes.length === 0 ? "" : " " + notes.length);
13030
13315
  approveButton.disabled = false;
13031
- diff.innerHTML = C.tableHtml(review, notes, expanded);
13032
- renderNotes();
13316
+
13317
+ diff.innerHTML = isInline()
13318
+ ? C.inlineHtml(review, notes, expanded)
13319
+ : C.tableHtml(review, notes, expanded);
13320
+
13321
+ renderMargin();
13322
+ refreshLeaders();
13323
+ }
13324
+
13325
+ // A card needs its final height before anything measures it, so the placement waits for the paint.
13326
+ function refreshLeaders() {
13327
+ requestAnimationFrame(() => {
13328
+ placeCards();
13329
+ drawLeaders();
13330
+ });
13331
+ }
13332
+
13333
+ function setLayout(next) {
13334
+ layout = next;
13335
+
13336
+ document.querySelectorAll(".swap button").forEach((button) => {
13337
+ button.setAttribute("aria-pressed", String(button.dataset.layout === next));
13338
+ });
13339
+
13340
+ hidePopup();
13341
+ render();
13033
13342
  }
13034
13343
 
13035
13344
  function cellOf(node) {
@@ -13063,9 +13372,11 @@ function readSelection() {
13063
13372
  return span === null ? null : { span, rect: range.getBoundingClientRect() };
13064
13373
  }
13065
13374
 
13375
+ // A hidden textarea still holds focus, and the key handler ignores every key while it does.
13066
13376
  function hidePopup() {
13067
13377
  popup.style.display = "none";
13068
13378
  popupText.value = "";
13379
+ popupText.blur();
13069
13380
  draft = null;
13070
13381
  }
13071
13382
 
@@ -13074,7 +13385,7 @@ function showPopup(selected) {
13074
13385
  popupQuote.textContent = C.quoteOf(review, selected.span) || "(whole line)";
13075
13386
 
13076
13387
  const top = window.scrollY + selected.rect.bottom + 8;
13077
- const left = Math.max(8, Math.min(window.scrollX + selected.rect.left, window.innerWidth - 320));
13388
+ const left = Math.max(8, Math.min(window.scrollX + selected.rect.left, window.innerWidth - 340));
13078
13389
 
13079
13390
  popup.style.top = top + "px";
13080
13391
  popup.style.left = left + "px";
@@ -13093,7 +13404,7 @@ diff.addEventListener("mouseup", () => {
13093
13404
  });
13094
13405
 
13095
13406
  diff.addEventListener("click", (event) => {
13096
- const fold = event.target.closest("[data-fold]");
13407
+ const fold = event.target instanceof Element ? event.target.closest("[data-fold]") : null;
13097
13408
 
13098
13409
  if (fold === null) {
13099
13410
  return;
@@ -13131,8 +13442,8 @@ popupText.addEventListener("keydown", (event) => {
13131
13442
  }
13132
13443
  });
13133
13444
 
13134
- notesPanel.addEventListener("click", (event) => {
13135
- const drop = event.target.closest("[data-drop]");
13445
+ document.addEventListener("click", (event) => {
13446
+ const drop = event.target instanceof Element ? event.target.closest("[data-drop]") : null;
13136
13447
 
13137
13448
  if (drop === null) {
13138
13449
  return;
@@ -13143,6 +13454,44 @@ notesPanel.addEventListener("click", (event) => {
13143
13454
  render();
13144
13455
  });
13145
13456
 
13457
+ document.querySelectorAll(".swap button").forEach((button) => {
13458
+ button.addEventListener("click", () => setLayout(button.dataset.layout));
13459
+ });
13460
+
13461
+ // The pane binds u to the same swap, so the browser answers the key a pair mode user already knows.
13462
+ document.addEventListener("keydown", (event) => {
13463
+ if (event.target === popupText || event.metaKey || event.ctrlKey || event.altKey) {
13464
+ return;
13465
+ }
13466
+
13467
+ if (event.key === "u") {
13468
+ setLayout(isInline() ? "split" : "inline");
13469
+ }
13470
+ });
13471
+
13472
+ // The footer names ^S and ^Q, so both keys act rather than reaching the browser.
13473
+ document.addEventListener("keydown", (event) => {
13474
+ if (!event.ctrlKey || event.metaKey || event.altKey || review === null) {
13475
+ return;
13476
+ }
13477
+
13478
+ if (event.key === "s" && notes.length > 0) {
13479
+ event.preventDefault();
13480
+ post({ id: review.id, notes });
13481
+ return;
13482
+ }
13483
+
13484
+ if (event.key === "q") {
13485
+ event.preventDefault();
13486
+ post({ id: review.id, notes: [] });
13487
+ }
13488
+ });
13489
+
13490
+ window.addEventListener("resize", () => {
13491
+ placeCards();
13492
+ drawLeaders();
13493
+ });
13494
+
13146
13495
  function warn(message) {
13147
13496
  pathLabel.textContent = message;
13148
13497
  }
@@ -13211,7 +13560,10 @@ function inlineScript(source) {
13211
13560
  }
13212
13561
  var SCRIPT = inlineScript(`${CLIENT_BUNDLE}
13213
13562
  ${WIRING}`);
13214
- function renderPage() {
13563
+ function pressed(layout, wanted) {
13564
+ return layout === wanted ? "true" : "false";
13565
+ }
13566
+ function renderPage(layout = "split") {
13215
13567
  return `<!doctype html>
13216
13568
  <html lang="en">
13217
13569
  <head>
@@ -13220,25 +13572,35 @@ function renderPage() {
13220
13572
  <title>pair mode</title>
13221
13573
  <style>${STYLE}</style>
13222
13574
  </head>
13223
- <body>
13575
+ <body data-layout="${layout}">
13224
13576
  <header>
13225
- <span>pair mode</span>
13226
- <span class="path" id="path">no review open</span>
13227
- <button id="approve">Approve</button>
13228
- <button id="send">Send notes</button>
13577
+ <span class="seg mode">PAIR</span>
13578
+ <span class="seg path" id="path">no review open</span>
13579
+ <span class="seg plus" id="plus"></span>
13580
+ <span class="seg minus" id="minus"></span>
13581
+ <span class="seg tally" id="tally"></span>
13229
13582
  </header>
13230
13583
  <main>
13584
+ <svg id="leaders" aria-hidden="true"></svg>
13231
13585
  <div id="diff"></div>
13232
- <aside>
13233
- <h2>Notes</h2>
13234
- <div id="notes"></div>
13235
- </aside>
13586
+ <div id="margin"></div>
13236
13587
  </main>
13588
+ <footer>
13589
+ <span class="seg">drag to <span class="key">note</span></span>
13590
+ <span class="seg">click a fold to <span class="key">open</span></span>
13591
+ <span class="swap" role="group" aria-label="Layout">
13592
+ <button data-layout="inline" aria-pressed="${pressed(layout, "inline")}">u inline</button>
13593
+ <button data-layout="split" aria-pressed="${pressed(layout, "split")}">u split</button>
13594
+ </span>
13595
+ <span class="fill"></span>
13596
+ <button class="act ok" id="approve">^Q approve</button>
13597
+ <button class="act send" id="send">^S send</button>
13598
+ </footer>
13237
13599
  <div id="popup">
13238
13600
  <div class="quote" id="popup-quote"></div>
13239
13601
  <textarea id="popup-text" placeholder="What do you want to ask?"></textarea>
13240
13602
  <div class="row">
13241
- <button id="popup-save">Add note</button>
13603
+ <button class="save" id="popup-save">Add note</button>
13242
13604
  <button id="popup-cancel">Cancel</button>
13243
13605
  </div>
13244
13606
  <div class="hint">Enter saves. Shift-Enter makes a new line.</div>
@@ -13416,7 +13778,7 @@ data: ${data}
13416
13778
  const url = request.url ?? "";
13417
13779
  if (url === base && request.method === "GET") {
13418
13780
  response.writeHead(OK, { "content-type": "text/html; charset=utf-8" });
13419
- response.end(renderPage());
13781
+ response.end(renderPage(options.layout));
13420
13782
  return;
13421
13783
  }
13422
13784
  if (url === `${base}/events` && request.method === "GET") {
@@ -13516,6 +13878,7 @@ async function startWebWatch(options, config) {
13516
13878
  const web = await startWebServer({
13517
13879
  port: options.port,
13518
13880
  token: options.token,
13881
+ layout: config.layout,
13519
13882
  onVerdict(id, questions) {
13520
13883
  client.write(encode({ type: "verdict", id, questions }));
13521
13884
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pair-mode",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Annotate a coding agent's proposed edits line by line, in your terminal.",
5
5
  "keywords": [
6
6
  "ai",
@@ -37,6 +37,7 @@
37
37
  "scripts": {
38
38
  "build": "node scripts/build.mjs",
39
39
  "capture": "node scripts/capture-fixtures.mjs",
40
+ "release": "node scripts/release.mjs",
40
41
  "test": "vitest run",
41
42
  "test:watch": "vitest",
42
43
  "typecheck": "tsc --noEmit",