tmux-fuzzy-motion 0.0.5 → 0.0.8

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 (4) hide show
  1. package/README.ja.md +43 -20
  2. package/README.md +48 -20
  3. package/dist/cli.js +336 -33
  4. package/package.json +1 -1
package/README.ja.md CHANGED
@@ -2,14 +2,16 @@
2
2
 
3
3
  [English README](./README.md)
4
4
 
5
- `tmux-fuzzy-motion` は、`tmux copy-mode` 内で素早くカーソル移動するための
6
- CLI です。現在の viewport からジャンプ対象を抽出し、fuzzy search で絞り込み、
5
+ `tmux-fuzzy-motion` は、tmux pane 内で素早くカーソル移動するための CLI です。
6
+ 現在の viewport からジャンプ対象を抽出し、fuzzy search で絞り込み、
7
7
  大文字の hint で移動できます。英字のローマ字 query に対しては Migemo による
8
8
  日本語マッチも行います。
9
9
 
10
10
  ## 特徴
11
11
 
12
12
  - `tmux copy-mode` 内で動作
13
+ - `start` は copy mode の外から起動しても自動で copy mode に入る
14
+ - `start --scope all` は current window の visible pane 全体を対象にできる
13
15
  - 現在の viewport から URL、path、filename、symbol、一般的な単語を抽出
14
16
  - `fzf` による fuzzy match
15
17
  - `jsmigemo` による英字 query の Migemo マッチ
@@ -56,13 +58,32 @@ bind-key -T copy-mode-vi s run-shell 'tmux-fuzzy-motion start #{pane_id} #{clien
56
58
  bind-key -T copy-mode s run-shell 'tmux-fuzzy-motion start #{pane_id} #{client_tty}'
57
59
  ```
58
60
 
59
- `run-shell` を経由せずに tmux から直接 popup を開きたい場合は、次の設定も使えます。
61
+ copy mode の外からも起動したい場合は、root table にも bind を追加します。
62
+
63
+ ```tmux
64
+ bind-key s run-shell 'tmux-fuzzy-motion start #{pane_id} #{client_tty}'
65
+ ```
66
+
67
+ current window の visible pane 全体から選びたい場合は、`--scope all` を付けた bind を追加します。
68
+
69
+ ```tmux
70
+ bind-key S run-shell 'tmux-fuzzy-motion start --scope all #{pane_id} #{client_tty}'
71
+ ```
72
+
73
+ `start` サブコマンドを経由せずに tmux から直接 popup を開きたい場合は、次の設定も使えます。
60
74
 
61
75
  ```tmux
62
76
  bind-key -T copy-mode-vi s run-shell -C "display-popup -E -B -x '##{popup_pane_left}' -y '##{popup_pane_top}' -w '#{pane_width}' -h '#{pane_height}' 'tmux-fuzzy-motion popup-live #{pane_id}'"
63
77
  bind-key -T copy-mode s run-shell -C "display-popup -E -B -x '##{popup_pane_left}' -y '##{popup_pane_top}' -w '#{pane_width}' -h '#{pane_height}' 'tmux-fuzzy-motion popup-live #{pane_id}'"
64
78
  ```
65
79
 
80
+ > [!NOTE]
81
+ > 後述の手順の`2.`で`'tmux-fuzzy-motion start %25 /dev/ttys000' returned 127`のようなエラーが表示される場合は以下のようにrun-shell環境のPATHに`tmux-fuzzy-motion`を含める必要があります。
82
+ >
83
+ > ```tmux
84
+ > set-environment -g PATH "/path/to/node/bin:$PATH"
85
+ > ```
86
+
66
87
  設定変更後は tmux を reload します。
67
88
 
68
89
  ```bash
@@ -71,13 +92,15 @@ tmux source-file ~/.tmux.conf
71
92
 
72
93
  ## 使い方
73
94
 
74
- 1. `copy-mode` に入る
75
- 2. `s` を押す
76
- 3. 小文字や記号で query を入力する
77
- 4. fuzzy match で候補を絞り込む
78
- 5. 英字 query の場合は Migemo による日本語候補も対象になる
79
- 6. 大文字 hint を押して即座に移動する
80
- 7. `Esc` または `Ctrl-[` でキャンセルする
95
+ 1. `tmux-fuzzy-motion start` を bind したキーを押す
96
+ 2. `--scope current`(default)は current pane のみを対象にし、pane がまだ `copy-mode` でなければ先に `copy-mode` に入る
97
+ 3. `--scope all` は current window の visible pane 全体を popup に合成して対象にする
98
+ 4. 小文字や記号で query を入力する
99
+ 5. fuzzy match で候補を絞り込む
100
+ 6. 英字 query の場合は Migemo による日本語候補も対象になる
101
+ 7. 大文字 hint を押して即座に移動する
102
+ 8. `--scope all` で選択した場合は、該当 pane を active にして必要なら `copy-mode` に入ってから移動する
103
+ 9. `Esc` または `Ctrl-[` でキャンセルする
81
104
 
82
105
  ## 入力キー
83
106
 
@@ -91,7 +114,7 @@ tmux source-file ~/.tmux.conf
91
114
  ## コマンド
92
115
 
93
116
  ```text
94
- tmux-fuzzy-motion start <pane-id> <client-tty>
117
+ tmux-fuzzy-motion start [--scope current|all] <pane-id> <client-tty>
95
118
  tmux-fuzzy-motion popup-live <pane-id>
96
119
  tmux-fuzzy-motion doctor
97
120
  ```
@@ -99,6 +122,11 @@ tmux-fuzzy-motion doctor
99
122
  `popup` と `daemon` は内部サブコマンドです。`popup-live` は `display-popup`
100
123
  から直接起動する設定向けです。
101
124
 
125
+ `--scope`:
126
+
127
+ - `current`: current pane のみを対象にする。default
128
+ - `all`: current window の visible pane 全体を対象にする
129
+
102
130
  ## Doctor
103
131
 
104
132
  ローカル環境の確認には `doctor` を使います。
@@ -141,17 +169,12 @@ pnpm run dev
141
169
  pnpm check
142
170
  ```
143
171
 
144
- warm な daemon を前提に popup 起動時間を測る場合:
145
-
146
- ```bash
147
- pnpm bench:startup dist/cli.js
148
- ```
149
-
150
172
  ## 制約
151
173
 
152
- - 対象は現在の viewport のみ
153
- - `copy-mode` 専用
174
+ - 対象は各 pane の現在の viewport のみ
175
+ - `--scope all` の対象は current window の visible pane のみ
176
+ - zoom 中の `--scope all` は見えている pane のみを対象にする
154
177
  - query 入力は ASCII 寄り
155
178
  - combining character の完全な扱いは未保証
156
179
  - `display-popup` が必要なため、tmux 3.2 以上が必須
157
- - query は pane 内の最下行右端に描画する
180
+ - query は popup の最下行右端に描画する
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [日本語版はこちら](./README.ja.md)
4
4
 
5
- `tmux-fuzzy-motion` is a CLI for quick cursor jumps inside `tmux copy-mode`.
5
+ `tmux-fuzzy-motion` is a CLI for quick cursor jumps in tmux panes.
6
6
  It scans the current viewport, extracts jump targets, filters them with fuzzy
7
7
  search, and lets you jump with uppercase hints. Roman queries can also match
8
8
  Japanese text through Migemo.
@@ -10,6 +10,8 @@ Japanese text through Migemo.
10
10
  ## Features
11
11
 
12
12
  - Works inside `tmux copy-mode`
13
+ - `start` can also be launched outside copy-mode and enters copy-mode automatically
14
+ - `start --scope all` can target every visible pane in the current window
13
15
  - Extracts URLs, paths, filenames, symbols, and general words from the current viewport
14
16
  - Supports fuzzy matching with `fzf`
15
17
  - Supports Migemo matching for alphabetic queries via `jsmigemo`
@@ -56,14 +58,35 @@ bind-key -T copy-mode-vi s run-shell 'tmux-fuzzy-motion start #{pane_id} #{clien
56
58
  bind-key -T copy-mode s run-shell 'tmux-fuzzy-motion start #{pane_id} #{client_tty}'
57
59
  ```
58
60
 
59
- If you want tmux to open the popup directly and avoid the extra `run-shell`
60
- hop, use this instead:
61
+ If you also want to launch it outside copy-mode, add a binding in the root
62
+ table as well:
63
+
64
+ ```tmux
65
+ bind-key s run-shell 'tmux-fuzzy-motion start #{pane_id} #{client_tty}'
66
+ ```
67
+
68
+ If you want to search across every visible pane in the current window, add a
69
+ binding with `--scope all`.
70
+
71
+ ```tmux
72
+ bind-key S run-shell 'tmux-fuzzy-motion start --scope all #{pane_id} #{client_tty}'
73
+ ```
74
+
75
+ If you want tmux to open the popup directly without going through the `start`
76
+ subcommand, use this instead:
61
77
 
62
78
  ```tmux
63
79
  bind-key -T copy-mode-vi s run-shell -C "display-popup -E -B -x '##{popup_pane_left}' -y '##{popup_pane_top}' -w '#{pane_width}' -h '#{pane_height}' 'tmux-fuzzy-motion popup-live #{pane_id}'"
64
80
  bind-key -T copy-mode s run-shell -C "display-popup -E -B -x '##{popup_pane_left}' -y '##{popup_pane_top}' -w '#{pane_width}' -h '#{pane_height}' 'tmux-fuzzy-motion popup-live #{pane_id}'"
65
81
  ```
66
82
 
83
+ > [!NOTE]
84
+ > If you see an error like `'tmux-fuzzy-motion start %25 /dev/ttys000' returned 127` at step 2 below, you need to add `tmux-fuzzy-motion` to the PATH in the run-shell environment:
85
+ >
86
+ > ```tmux
87
+ > set-environment -g PATH "/path/to/node/bin:$PATH"
88
+ > ```
89
+
67
90
  Reload tmux after editing the config:
68
91
 
69
92
  ```bash
@@ -72,13 +95,18 @@ tmux source-file ~/.tmux.conf
72
95
 
73
96
  ## Usage
74
97
 
75
- 1. Enter `copy-mode`.
76
- 2. Press `s`.
77
- 3. Type a query in lowercase or symbols.
78
- 4. Narrow the candidates with fuzzy matching.
79
- 5. For alphabetic queries, Migemo also expands roman input to Japanese matches.
80
- 6. Press an uppercase hint to jump immediately.
81
- 7. Press `Esc` or `Ctrl-[` to cancel.
98
+ 1. Press the key bound to `tmux-fuzzy-motion start`.
99
+ 2. `--scope current` (the default) targets only the current pane and enters
100
+ copy-mode first if needed.
101
+ 3. `--scope all` targets every visible pane in the current window by composing
102
+ them into a single popup.
103
+ 4. Type a query in lowercase or symbols.
104
+ 5. Narrow the candidates with fuzzy matching.
105
+ 6. For alphabetic queries, Migemo also expands roman input to Japanese matches.
106
+ 7. Press an uppercase hint to jump immediately.
107
+ 8. In `--scope all`, the selected pane becomes active and enters copy-mode if
108
+ needed before the cursor moves.
109
+ 9. Press `Esc` or `Ctrl-[` to cancel.
82
110
 
83
111
  ## Input Keys
84
112
 
@@ -92,7 +120,7 @@ tmux source-file ~/.tmux.conf
92
120
  ## Commands
93
121
 
94
122
  ```text
95
- tmux-fuzzy-motion start <pane-id> <client-tty>
123
+ tmux-fuzzy-motion start [--scope current|all] <pane-id> <client-tty>
96
124
  tmux-fuzzy-motion popup-live <pane-id>
97
125
  tmux-fuzzy-motion doctor
98
126
  ```
@@ -100,6 +128,11 @@ tmux-fuzzy-motion doctor
100
128
  `popup` and `daemon` are internal subcommands. `popup-live` is intended for
101
129
  direct `display-popup` bindings.
102
130
 
131
+ `--scope`:
132
+
133
+ - `current`: target only the current pane. This is the default.
134
+ - `all`: target every visible pane in the current window.
135
+
103
136
  ## Doctor
104
137
 
105
138
  Use `doctor` to verify the local environment:
@@ -142,17 +175,12 @@ Run the full local check:
142
175
  pnpm check
143
176
  ```
144
177
 
145
- Measure popup startup against a warm daemon:
146
-
147
- ```bash
148
- pnpm bench:startup dist/cli.js
149
- ```
150
-
151
178
  ## Limitations
152
179
 
153
- - Targets are limited to the current viewport
154
- - Designed for `copy-mode` only
180
+ - Targets are limited to the current viewport of each pane
181
+ - `--scope all` targets only visible panes in the current window
182
+ - Zoomed windows with `--scope all` only target the pane that is visible
155
183
  - Query input is ASCII-oriented
156
184
  - Exact behavior for combining characters is not fully guaranteed
157
185
  - Requires `display-popup`, so tmux 3.2 or later is mandatory
158
- - The query is drawn on the bottom row inside the pane, aligned to the right edge
186
+ - The query is drawn on the popup's bottom row, aligned to the right edge
package/dist/cli.js CHANGED
@@ -1,11 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { execFile, spawn } from "node:child_process";
4
- import { access, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { access, appendFile, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
5
5
  import { dirname, join, resolve } from "node:path";
6
6
  import { createConnection, createServer } from "node:net";
7
7
  import { tmpdir } from "node:os";
8
8
  import { createHash } from "node:crypto";
9
+ import { statSync } from "node:fs";
9
10
  //#endregion
10
11
  //#region src/infra/process.ts
11
12
  const runProcess = async (command, args) => new Promise((resolve, reject) => {
@@ -52,6 +53,17 @@ const focusClientPane = async (tmux, paneId, clientTty) => {
52
53
  throw new Error("tmux-fuzzy-motion: client not found", { cause: error });
53
54
  }
54
55
  };
56
+ const enterCopyMode = async (tmux, paneId) => {
57
+ try {
58
+ await tmux.run([
59
+ "copy-mode",
60
+ "-t",
61
+ paneId
62
+ ]);
63
+ } catch (error) {
64
+ throw new Error("tmux-fuzzy-motion: failed to enter copy-mode", { cause: error });
65
+ }
66
+ };
55
67
  const getPaneStartContext = async (tmux, paneId) => {
56
68
  let output = "";
57
69
  try {
@@ -67,10 +79,9 @@ const getPaneStartContext = async (tmux, paneId) => {
67
79
  }
68
80
  const [resolvedPaneId, paneInMode, width, height, currentPath] = output.split(" ");
69
81
  if (!resolvedPaneId || !paneInMode || !currentPath || [width, height].map((value) => Number(value)).some((value) => !Number.isFinite(value))) throw new Error("tmux-fuzzy-motion: failed to resolve pane context");
70
- if (paneInMode !== "1") throw new Error("tmux-fuzzy-motion: pane is not in copy-mode");
71
82
  return {
72
83
  paneId: resolvedPaneId,
73
- inCopyMode: true,
84
+ inCopyMode: paneInMode === "1",
74
85
  width: Number(width),
75
86
  height: Number(height),
76
87
  currentPath
@@ -88,9 +99,9 @@ const displayPopup = async (tmux, options) => {
88
99
  "-d",
89
100
  options.currentPath,
90
101
  "-x",
91
- "#{popup_pane_left}",
102
+ String(options.x ?? "#{popup_pane_left}"),
92
103
  "-y",
93
- "#{popup_pane_top}",
104
+ String(options.y ?? "#{popup_pane_top}"),
94
105
  "-w",
95
106
  String(options.width),
96
107
  "-h",
@@ -98,6 +109,64 @@ const displayPopup = async (tmux, options) => {
98
109
  ...options.command
99
110
  ]);
100
111
  };
112
+ const listWindowPanes = async (tmux, paneId) => {
113
+ let output = "";
114
+ try {
115
+ output = (await tmux.capture([
116
+ "list-panes",
117
+ "-t",
118
+ paneId,
119
+ "-F",
120
+ "#{pane_id} #{pane_in_mode} #{pane_width} #{pane_height} #{pane_current_path} #{pane_left} #{pane_top} #{?pane_active,1,0} #{window_zoomed_flag}"
121
+ ])).trim();
122
+ } catch (error) {
123
+ throw new Error("tmux-fuzzy-motion: pane not found", { cause: error });
124
+ }
125
+ const panes = output.split("\n").filter((line) => line.length > 0).map((line) => {
126
+ const [resolvedPaneId, paneInMode, width, height, currentPath, left, top, active, zoomed] = line.split(" ");
127
+ const numeric = [
128
+ width,
129
+ height,
130
+ left,
131
+ top
132
+ ].map((value) => Number(value));
133
+ if (!resolvedPaneId || !paneInMode || !currentPath || [active, zoomed].some((value) => value === void 0) || numeric.some((value) => !Number.isFinite(value))) throw new Error("tmux-fuzzy-motion: failed to resolve window panes");
134
+ return {
135
+ paneId: resolvedPaneId,
136
+ inCopyMode: paneInMode === "1",
137
+ width: Number(width),
138
+ height: Number(height),
139
+ currentPath,
140
+ left: Number(left),
141
+ top: Number(top),
142
+ active: active === "1",
143
+ zoomed: zoomed === "1"
144
+ };
145
+ });
146
+ if (panes.length === 0) throw new Error("tmux-fuzzy-motion: pane not found");
147
+ const zoomed = panes.some((pane) => pane.zoomed);
148
+ return panes.filter((pane) => !zoomed || pane.active).map((pane) => {
149
+ const { zoomed: paneZoomed, ...rest } = pane;
150
+ return rest;
151
+ });
152
+ };
153
+ const getPaneBorderLines = async (tmux, paneId) => {
154
+ let output = "";
155
+ try {
156
+ output = (await tmux.capture([
157
+ "show-options",
158
+ "-A",
159
+ "-wv",
160
+ "-t",
161
+ paneId,
162
+ "pane-border-lines"
163
+ ])).trim();
164
+ } catch (error) {
165
+ throw new Error("tmux-fuzzy-motion: failed to resolve pane border lines", { cause: error });
166
+ }
167
+ if (output === "single" || output === "double" || output === "heavy" || output === "simple" || output === "number" || output === "spaces") return output;
168
+ throw new Error("tmux-fuzzy-motion: failed to resolve pane border lines");
169
+ };
101
170
  const getTmuxVersion = async () => {
102
171
  return (await runProcess("tmux", ["-V"])).stdout.trim();
103
172
  };
@@ -4789,7 +4858,7 @@ const extractCandidates = (lines) => {
4789
4858
  //#region src/core/hint.ts
4790
4859
  const HINT_CHARS$1 = "ASDFGHJKLQWERTYUIOPZXCVBNM";
4791
4860
  const MAX_TARGETS = 200;
4792
- const createTargetKey = (target) => `${target.line}:${target.col}:${target.text}`;
4861
+ const createTargetKey = (target) => `${target.paneId ?? ""}:${target.line}:${target.col}:${target.text}`;
4793
4862
  const generateHints = (characters, maxHintLength) => {
4794
4863
  const single = [...characters];
4795
4864
  if (maxHintLength === 1) return single;
@@ -5602,6 +5671,7 @@ var Fzf = class {
5602
5671
  //#endregion
5603
5672
  //#region src/core/matcher.ts
5604
5673
  const candidateKey = (candidate) => [
5674
+ candidate.paneId ?? "",
5605
5675
  candidate.kind,
5606
5676
  candidate.text,
5607
5677
  String(candidate.line),
@@ -5680,7 +5750,17 @@ const createMatcher = (candidates, migemo) => {
5680
5750
  //#region src/commands/runtime.ts
5681
5751
  const sleep = async (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
5682
5752
  const resolveCliEntrypoint = () => process.argv[1] ?? resolve(process.cwd(), "dist/cli.js");
5683
- const createDaemonSocketPath = () => join("/tmp", `tfm-${createHash("sha1").update(process.env.TMUX ?? "tmux-fuzzy-motion").digest("hex")}.sock`);
5753
+ const createDaemonIdentity = () => {
5754
+ const entrypoint = resolveCliEntrypoint();
5755
+ const entrypointMtimeMs = statSync(entrypoint).mtimeMs;
5756
+ return [
5757
+ process.env.TMUX ?? "tmux-fuzzy-motion",
5758
+ process.execPath,
5759
+ entrypoint,
5760
+ String(entrypointMtimeMs)
5761
+ ].join("\0");
5762
+ };
5763
+ const createDaemonSocketPath = () => join("/tmp", `tfm-${createHash("sha1").update(createDaemonIdentity()).digest("hex")}.sock`);
5684
5764
  const pathExists = async (path) => {
5685
5765
  try {
5686
5766
  await access(path);
@@ -5729,7 +5809,7 @@ const isDaemonHealthy = async (socketPath) => new Promise((resolve) => {
5729
5809
  });
5730
5810
  });
5731
5811
  const waitForDaemon = async (socketPath) => {
5732
- for (let attempt = 0; attempt < 40; attempt += 1) {
5812
+ for (let attempt = 0; attempt < 200; attempt += 1) {
5733
5813
  if (await isDaemonHealthy(socketPath)) return;
5734
5814
  await sleep(25);
5735
5815
  }
@@ -5773,14 +5853,16 @@ const measureCellWidth = (cells, start) => {
5773
5853
  const createOverlayRenderer = (lines) => {
5774
5854
  const baseCellsByLine = lines.map((line) => createStyledDisplayCells(line));
5775
5855
  const baseLines = baseCellsByLine.map((cells) => cells.join(""));
5856
+ const targetLine = (target) => (target.screenLine ?? target.line) - 1;
5857
+ const targetCol = (target) => target.screenCol ?? target.col;
5776
5858
  return (targets) => {
5777
5859
  const rendered = [...baseLines];
5778
5860
  const mutableCells = /* @__PURE__ */ new Map();
5779
5861
  const occupiedByLine = /* @__PURE__ */ new Map();
5780
5862
  const enterTarget = targets[0];
5781
- const sorted = [...targets].sort((left, right) => left.line - right.line || left.col + left.primary - (right.col + right.primary));
5863
+ const sorted = [...targets].sort((left, right) => targetLine(left) - targetLine(right) || targetCol(left) + left.primary - (targetCol(right) + right.primary));
5782
5864
  for (const target of sorted) {
5783
- const lineIndex = target.line - 1;
5865
+ const lineIndex = targetLine(target);
5784
5866
  const baseCells = baseCellsByLine[lineIndex];
5785
5867
  if (!baseCells) continue;
5786
5868
  let cells = mutableCells.get(lineIndex);
@@ -5793,14 +5875,14 @@ const createOverlayRenderer = (lines) => {
5793
5875
  lineOccupied = Array.from({ length: baseCells.length }, () => false);
5794
5876
  occupiedByLine.set(lineIndex, lineOccupied);
5795
5877
  }
5796
- const matchCol = target.col + target.primary;
5878
+ const matchCol = targetCol(target) + target.primary;
5797
5879
  const hintCol = findOverlayStart(cells, matchCol);
5798
5880
  const baseWidth = measureCellWidth(cells, hintCol);
5799
5881
  const paddedHint = hintCol < matchCol ? target.hint.padEnd(baseWidth, " ") : target.hint;
5800
5882
  const hintWidth = displayWidth(paddedHint);
5801
5883
  const shouldHighlightPrimary = hintCol < matchCol;
5802
- const highlightCols = target.positions.filter((position) => position !== target.primary || shouldHighlightPrimary).map((position) => target.col + position);
5803
- const isEnterTarget = enterTarget?.line === target.line && enterTarget.col === target.col && enterTarget.text === target.text;
5884
+ const highlightCols = target.positions.filter((position) => position !== target.primary || shouldHighlightPrimary).map((position) => targetCol(target) + position);
5885
+ const isEnterTarget = enterTarget !== void 0 && enterTarget.paneId === target.paneId && targetLine(enterTarget) === lineIndex && targetCol(enterTarget) === targetCol(target) && enterTarget.text === target.text;
5804
5886
  const hintStyle = isEnterTarget ? PRIMARY_HINT_STYLE : SECONDARY_HINT_STYLE;
5805
5887
  const highlightStyle = isEnterTarget ? PRIMARY_HIGHLIGHT_STYLE : SECONDARY_HIGHLIGHT_STYLE;
5806
5888
  const overlapsHint = Array.from({ length: hintWidth }, (_, offset) => lineOccupied[hintCol + offset]).some(Boolean);
@@ -6006,8 +6088,13 @@ const computeMatches = (query, previousHints, matcher) => assignHints(matcher(qu
6006
6088
  maxHintLength: 1,
6007
6089
  maxTargets: 26
6008
6090
  });
6009
- const createPreparedMatcher = async (lines, migemoPromise) => {
6010
- const candidates = extractCandidates(lines);
6091
+ const createPreparedMatcher = async (state, migemoPromise) => {
6092
+ const candidates = state.scope === "all" ? state.panes.flatMap((pane) => extractCandidates(pane.plainLines).map((candidate) => ({
6093
+ ...candidate,
6094
+ paneId: pane.paneId,
6095
+ screenLine: pane.top + candidate.line,
6096
+ screenCol: pane.left + candidate.col
6097
+ }))) : extractCandidates(state.plainLines);
6011
6098
  const migemo = await migemoPromise;
6012
6099
  return {
6013
6100
  candidateCount: candidates.length,
@@ -6193,8 +6280,10 @@ const runPopupLive = async (args) => {
6193
6280
  const socketPath = createDaemonSocketPath();
6194
6281
  try {
6195
6282
  const pane = await getPaneStartContext(tmux, paneId);
6283
+ if (!pane.inCopyMode) throw new Error("tmux-fuzzy-motion: pane is not in copy-mode");
6196
6284
  const capture = fitCaptureToHeight(await capturePane(tmux, paneId), pane.height);
6197
6285
  const state = {
6286
+ scope: "current",
6198
6287
  paneId,
6199
6288
  clientTty: "",
6200
6289
  displayLines: capture.displayLines,
@@ -6279,7 +6368,7 @@ const runDaemon = async (args) => {
6279
6368
  }
6280
6369
  if (request.type === "prepare") {
6281
6370
  activeSocket = socket;
6282
- const prepared = await createPreparedMatcher(JSON.parse(await readFile(request.stateFile, "utf8")).plainLines, migemoPromise);
6371
+ const prepared = await createPreparedMatcher(JSON.parse(await readFile(request.stateFile, "utf8")), migemoPromise);
6283
6372
  activeMatcher = prepared.matcher;
6284
6373
  await writeMessage({
6285
6374
  type: "prepared",
@@ -6337,6 +6426,21 @@ const runDaemon = async (args) => {
6337
6426
  };
6338
6427
  //#endregion
6339
6428
  //#region src/commands/start.ts
6429
+ const writeDebugLog = async (payload) => {
6430
+ const debugLogPath = process.env.TMUX_FUZZY_MOTION_DEBUG_LOG;
6431
+ if (!debugLogPath) return;
6432
+ const line = JSON.stringify({
6433
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
6434
+ ...payload
6435
+ });
6436
+ try {
6437
+ await appendFile(debugLogPath, `${line}\n`, "utf8");
6438
+ } catch {}
6439
+ };
6440
+ const buildPopupRelativePosition = (axis, targetOrigin) => {
6441
+ if (axis === "x") return `#{e|+|:#{popup_pane_left},#{e|-|:${targetOrigin},#{pane_left}}}`;
6442
+ return `#{e|+|:#{popup_height},${`#{e|+|:#{e|-|:${targetOrigin},#{window_offset_y}},#{?#{==:#{status-position},top},#{e|-|:#{client_height},#{window_height}},0}}`}}`;
6443
+ };
6340
6444
  const buildPopupCommand = (stateFile, resultFile, socketPath) => [
6341
6445
  process.execPath,
6342
6446
  resolveCliEntrypoint(),
@@ -6355,8 +6459,194 @@ const readResult = async (resultFile) => {
6355
6459
  throw new Error("tmux-fuzzy-motion: popup did not produce result", { cause: error });
6356
6460
  }
6357
6461
  };
6462
+ const parseStartArgs = (args) => {
6463
+ let scope = "current";
6464
+ const positional = [];
6465
+ for (let index = 0; index < args.length; index += 1) {
6466
+ const value = args[index];
6467
+ if (value === "--scope") {
6468
+ const nextScope = args[index + 1];
6469
+ if (nextScope === "current" || nextScope === "all") scope = nextScope;
6470
+ index += 1;
6471
+ continue;
6472
+ }
6473
+ positional.push(value ?? "");
6474
+ }
6475
+ return {
6476
+ scope,
6477
+ paneId: positional[0] ?? "",
6478
+ clientTty: positional[1] ?? ""
6479
+ };
6480
+ };
6481
+ const createBlankRow = (width) => Array.from({ length: width }, () => " ");
6482
+ const BORDER_SETS = {
6483
+ single: {
6484
+ vertical: "│",
6485
+ horizontal: "─",
6486
+ intersection: "┼"
6487
+ },
6488
+ double: {
6489
+ vertical: "║",
6490
+ horizontal: "═",
6491
+ intersection: "╬"
6492
+ },
6493
+ heavy: {
6494
+ vertical: "┃",
6495
+ horizontal: "━",
6496
+ intersection: "╋"
6497
+ },
6498
+ simple: {
6499
+ vertical: "|",
6500
+ horizontal: "-",
6501
+ intersection: "+"
6502
+ },
6503
+ spaces: {
6504
+ vertical: " ",
6505
+ horizontal: " ",
6506
+ intersection: " "
6507
+ }
6508
+ };
6509
+ const createOccupancyGrid = (panes, width, height) => {
6510
+ const occupied = Array.from({ length: height }, () => Array.from({ length: width }, () => false));
6511
+ for (const pane of panes) for (let row = pane.top; row < pane.top + pane.height; row += 1) {
6512
+ const line = occupied[row];
6513
+ if (!line) continue;
6514
+ for (let column = pane.left; column < pane.left + pane.width; column += 1) if (column >= 0 && column < line.length) line[column] = true;
6515
+ }
6516
+ return occupied;
6517
+ };
6518
+ const resolveBorderSet = (borderLines) => borderLines === "number" ? BORDER_SETS.simple : BORDER_SETS[borderLines];
6519
+ const drawPaneBorders = (rows, occupied, borderLines) => {
6520
+ const borderSet = resolveBorderSet(borderLines);
6521
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex += 1) {
6522
+ const row = rows[rowIndex];
6523
+ const occupiedRow = occupied[rowIndex];
6524
+ if (!row || !occupiedRow) continue;
6525
+ for (let columnIndex = 0; columnIndex < row.length; columnIndex += 1) {
6526
+ if (occupiedRow[columnIndex]) continue;
6527
+ const left = occupiedRow[columnIndex - 1] ?? false;
6528
+ const right = occupiedRow[columnIndex + 1] ?? false;
6529
+ const top = occupied[rowIndex - 1]?.[columnIndex] ?? false;
6530
+ const bottom = occupied[rowIndex + 1]?.[columnIndex] ?? false;
6531
+ const hasVertical = left || right;
6532
+ const hasHorizontal = top || bottom;
6533
+ if (hasVertical && hasHorizontal) row[columnIndex] = borderSet.intersection;
6534
+ else if (hasVertical) row[columnIndex] = borderSet.vertical;
6535
+ else if (hasHorizontal) row[columnIndex] = borderSet.horizontal;
6536
+ }
6537
+ }
6538
+ };
6539
+ const composeDisplayLines = (panes, width, height, borderLines) => {
6540
+ const rows = Array.from({ length: height }, () => createBlankRow(width));
6541
+ const occupied = createOccupancyGrid(panes, width, height);
6542
+ for (const pane of panes) pane.displayLines.forEach((line, lineIndex) => {
6543
+ const row = rows[pane.top + lineIndex];
6544
+ if (!row) return;
6545
+ createStyledDisplayCells(line).forEach((cell, cellIndex) => {
6546
+ const column = pane.left + cellIndex;
6547
+ if (column < 0 || column >= row.length) return;
6548
+ row[column] = cell;
6549
+ });
6550
+ });
6551
+ drawPaneBorders(rows, occupied, borderLines);
6552
+ return rows.map((row) => row.join(""));
6553
+ };
6554
+ const buildCurrentState = async (tmux, pane, paneId, clientTty) => {
6555
+ if (!pane.inCopyMode) await enterCopyMode(tmux, paneId);
6556
+ const capture = fitCaptureToHeight(await capturePane(tmux, paneId), pane.height);
6557
+ return {
6558
+ currentPath: pane.currentPath,
6559
+ state: {
6560
+ scope: "current",
6561
+ paneId,
6562
+ clientTty,
6563
+ displayLines: capture.displayLines,
6564
+ plainLines: capture.lines,
6565
+ width: pane.width,
6566
+ height: pane.height
6567
+ }
6568
+ };
6569
+ };
6570
+ const buildAllPaneState = async (tmux, pane, paneId, clientTty) => {
6571
+ const panes = await listWindowPanes(tmux, paneId);
6572
+ const borderLines = await getPaneBorderLines(tmux, paneId);
6573
+ const bounds = panes.reduce((accumulator, item) => ({
6574
+ left: Math.min(accumulator.left, item.left),
6575
+ top: Math.min(accumulator.top, item.top),
6576
+ right: Math.max(accumulator.right, item.left + item.width),
6577
+ bottom: Math.max(accumulator.bottom, item.top + item.height)
6578
+ }), {
6579
+ left: Number.POSITIVE_INFINITY,
6580
+ top: Number.POSITIVE_INFINITY,
6581
+ right: Number.NEGATIVE_INFINITY,
6582
+ bottom: Number.NEGATIVE_INFINITY
6583
+ });
6584
+ const snapshots = [];
6585
+ for (const item of panes) {
6586
+ const capture = fitCaptureToHeight(await capturePane(tmux, item.paneId), item.height);
6587
+ snapshots.push({
6588
+ paneId: item.paneId,
6589
+ inCopyMode: item.inCopyMode,
6590
+ width: item.width,
6591
+ height: item.height,
6592
+ left: item.left - bounds.left,
6593
+ top: item.top - bounds.top,
6594
+ plainLines: capture.lines,
6595
+ displayLines: capture.displayLines
6596
+ });
6597
+ }
6598
+ const width = Math.max(0, bounds.right - bounds.left);
6599
+ const height = Math.max(0, bounds.bottom - bounds.top);
6600
+ const x = buildPopupRelativePosition("x", bounds.left);
6601
+ const y = buildPopupRelativePosition("y", bounds.top);
6602
+ await writeDebugLog({
6603
+ event: "start.build-all-pane-state",
6604
+ paneId,
6605
+ clientTty,
6606
+ targetPane: {
6607
+ paneId: pane.paneId,
6608
+ width: pane.width,
6609
+ height: pane.height,
6610
+ inCopyMode: pane.inCopyMode,
6611
+ currentPath: pane.currentPath
6612
+ },
6613
+ bounds,
6614
+ size: {
6615
+ width,
6616
+ height
6617
+ },
6618
+ popupPosition: {
6619
+ x,
6620
+ y
6621
+ },
6622
+ panes: panes.map((item) => ({
6623
+ paneId: item.paneId,
6624
+ left: item.left,
6625
+ top: item.top,
6626
+ width: item.width,
6627
+ height: item.height,
6628
+ active: item.active,
6629
+ inCopyMode: item.inCopyMode,
6630
+ currentPath: item.currentPath
6631
+ }))
6632
+ });
6633
+ return {
6634
+ currentPath: pane.currentPath,
6635
+ x,
6636
+ y,
6637
+ state: {
6638
+ scope: "all",
6639
+ paneId,
6640
+ clientTty,
6641
+ displayLines: composeDisplayLines(snapshots, width, height, borderLines),
6642
+ panes: snapshots,
6643
+ width,
6644
+ height
6645
+ }
6646
+ };
6647
+ };
6358
6648
  const runStart = async (args) => {
6359
- const [paneId, clientTty] = args;
6649
+ const { scope, paneId, clientTty } = parseStartArgs(args);
6360
6650
  if (!process.env.TMUX) {
6361
6651
  console.error("tmux-fuzzy-motion: must be run inside tmux");
6362
6652
  return 2;
@@ -6377,33 +6667,46 @@ const runStart = async (args) => {
6377
6667
  try {
6378
6668
  const pane = await getPaneStartContext(tmux, paneId);
6379
6669
  await focusClientPane(tmux, paneId, clientTty);
6380
- const capture = fitCaptureToHeight(await capturePane(tmux, paneId), pane.height);
6381
- const state = {
6382
- paneId,
6383
- clientTty,
6384
- displayLines: capture.displayLines,
6385
- plainLines: capture.lines,
6386
- width: pane.width,
6387
- height: pane.height
6388
- };
6670
+ const popupState = scope === "all" ? await buildAllPaneState(tmux, pane, paneId, clientTty) : await buildCurrentState(tmux, pane, paneId, clientTty);
6671
+ const state = popupState.state;
6389
6672
  await writeFile(stateFile, JSON.stringify(state), "utf8");
6390
6673
  await ensureDaemon(socketPath);
6391
- await displayPopup(tmux, {
6674
+ const popupOptions = {
6392
6675
  command: buildPopupCommand(stateFile, resultFile, socketPath),
6393
- currentPath: pane.currentPath,
6394
- height: pane.height,
6676
+ currentPath: popupState.currentPath,
6677
+ height: state.height,
6395
6678
  targetClient: clientTty,
6396
6679
  targetPane: paneId,
6397
- width: pane.width
6680
+ width: state.width
6681
+ };
6682
+ if (popupState.x !== void 0) popupOptions.x = popupState.x;
6683
+ if (popupState.y !== void 0) popupOptions.y = popupState.y;
6684
+ await writeDebugLog({
6685
+ event: "start.display-popup",
6686
+ scope,
6687
+ paneId,
6688
+ clientTty,
6689
+ popupOptions: {
6690
+ targetPane: popupOptions.targetPane,
6691
+ targetClient: popupOptions.targetClient,
6692
+ currentPath: popupOptions.currentPath,
6693
+ width: popupOptions.width,
6694
+ height: popupOptions.height,
6695
+ x: popupOptions.x ?? "#{popup_pane_left}",
6696
+ y: popupOptions.y ?? "#{popup_pane_top}"
6697
+ }
6398
6698
  });
6699
+ await displayPopup(tmux, popupOptions);
6399
6700
  const result = await readResult(resultFile);
6400
6701
  if (result.status === "selected") {
6702
+ const targetPaneId = result.target.paneId ?? paneId;
6401
6703
  await tmux.runQuiet([
6402
6704
  "select-pane",
6403
6705
  "-t",
6404
- paneId
6706
+ targetPaneId
6405
6707
  ]);
6406
- await moveCopyCursor(tmux, paneId, result.target);
6708
+ if (state.scope === "all" && !state.panes.some((targetPane) => targetPane.paneId === targetPaneId && targetPane.inCopyMode)) await enterCopyMode(tmux, targetPaneId);
6709
+ await moveCopyCursor(tmux, targetPaneId, result.target);
6407
6710
  }
6408
6711
  return 0;
6409
6712
  } catch (error) {
@@ -6422,7 +6725,7 @@ const runStart = async (args) => {
6422
6725
  const usage = `tmux-fuzzy-motion
6423
6726
 
6424
6727
  Usage:
6425
- tmux-fuzzy-motion start <pane-id> <client-tty>
6728
+ tmux-fuzzy-motion start [--scope current|all] <pane-id> <client-tty>
6426
6729
  tmux-fuzzy-motion popup --state-file <path> --result-file <path> --socket <path>
6427
6730
  tmux-fuzzy-motion popup-live <pane-id>
6428
6731
  tmux-fuzzy-motion daemon --socket <path>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tmux-fuzzy-motion",
3
- "version": "0.0.5",
3
+ "version": "0.0.8",
4
4
  "description": "Fuzzy hint motion for tmux copy-mode",
5
5
  "type": "module",
6
6
  "files": [