@fresh-editor/fresh-editor 0.2.24 → 0.3.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.
@@ -32,9 +32,7 @@
32
32
  "status.move_to_diff": "Move cursor to a diff line",
33
33
 
34
34
  "panel.commits_header": "Commits:",
35
- "panel.no_commits": " No commits found",
36
- "panel.log_footer": "%{count} commits | Up/Down/j/k: navigate | RET: show | y: yank hash | r: refresh | q: quit",
37
- "panel.detail_footer": "Up/Down/j/k: navigate | RET: open file at line | q: back to log"
35
+ "panel.no_commits": " No commits found"
38
36
  },
39
37
  "cs": {
40
38
  "cmd.git_log": "Git Log",
@@ -69,9 +67,7 @@
69
67
  "status.move_to_diff": "Presunte kurzor na radek diffu",
70
68
 
71
69
  "panel.commits_header": "Commity:",
72
- "panel.no_commits": " Zadne commity nenalezeny",
73
- "panel.log_footer": "%{count} commitu | Nahoru/Dolu/j/k: navigace | RET: zobrazit | y: kopirovat hash | r: obnovit | q: ukoncit",
74
- "panel.detail_footer": "Nahoru/Dolu/j/k: navigace | RET: otevrit soubor na radku | q: zpet do logu"
70
+ "panel.no_commits": " Zadne commity nenalezeny"
75
71
  },
76
72
  "de": {
77
73
  "cmd.git_log": "Git-Protokoll",
@@ -106,9 +102,7 @@
106
102
  "status.move_to_diff": "Cursor auf eine Diff-Zeile bewegen",
107
103
 
108
104
  "panel.commits_header": "Commits:",
109
- "panel.no_commits": " Keine Commits gefunden",
110
- "panel.log_footer": "%{count} Commits | Auf/Ab/j/k: navigieren | RET: anzeigen | y: Hash kopieren | r: aktualisieren | q: beenden",
111
- "panel.detail_footer": "Auf/Ab/j/k: navigieren | RET: Datei bei Zeile oeffnen | q: zurueck zum Protokoll"
105
+ "panel.no_commits": " Keine Commits gefunden"
112
106
  },
113
107
  "es": {
114
108
  "cmd.git_log": "Registro Git",
@@ -143,9 +137,7 @@
143
137
  "status.move_to_diff": "Mueve el cursor a una linea de diff",
144
138
 
145
139
  "panel.commits_header": "Commits:",
146
- "panel.no_commits": " No se encontraron commits",
147
- "panel.log_footer": "%{count} commits | Arriba/Abajo/j/k: navegar | RET: mostrar | y: copiar hash | r: actualizar | q: salir",
148
- "panel.detail_footer": "Arriba/Abajo/j/k: navegar | RET: abrir archivo en linea | q: volver al registro"
140
+ "panel.no_commits": " No se encontraron commits"
149
141
  },
150
142
  "fr": {
151
143
  "cmd.git_log": "Journal Git",
@@ -180,9 +172,7 @@
180
172
  "status.move_to_diff": "Deplacez le curseur sur une ligne de diff",
181
173
 
182
174
  "panel.commits_header": "Commits:",
183
- "panel.no_commits": " Aucun commit trouve",
184
- "panel.log_footer": "%{count} commits | Haut/Bas/j/k: naviguer | RET: afficher | y: copier hash | r: actualiser | q: quitter",
185
- "panel.detail_footer": "Haut/Bas/j/k: naviguer | RET: ouvrir fichier a la ligne | q: retour au journal"
175
+ "panel.no_commits": " Aucun commit trouve"
186
176
  },
187
177
  "it": {
188
178
  "cmd.git_log": "Git Log",
@@ -215,9 +205,7 @@
215
205
  "status.move_to_diff_with_context": "Sposta il cursore su una riga di diff con contesto file",
216
206
  "status.move_to_diff": "Sposta il cursore su una riga di diff",
217
207
  "panel.commits_header": "Commit:",
218
- "panel.no_commits": " Nessun commit trovato",
219
- "panel.log_footer": "%{count} commit | Su/Giù/j/k: naviga | RET: mostra | y: copia hash | r: aggiorna | q: esci",
220
- "panel.detail_footer": "Su/Giù/j/k: naviga | RET: apri file alla riga | q: torna al log"
208
+ "panel.no_commits": " Nessun commit trovato"
221
209
  },
222
210
  "ja": {
223
211
  "cmd.git_log": "Gitログ",
@@ -252,9 +240,7 @@
252
240
  "status.move_to_diff": "カーソルを差分行に移動してください",
253
241
 
254
242
  "panel.commits_header": "コミット:",
255
- "panel.no_commits": " コミットが見つかりません",
256
- "panel.log_footer": "%{count}件のコミット | 上/下/j/k: 移動 | RET: 表示 | y: ハッシュをコピー | r: 更新 | q: 終了",
257
- "panel.detail_footer": "上/下/j/k: 移動 | RET: ファイルを行で開く | q: ログに戻る"
243
+ "panel.no_commits": " コミットが見つかりません"
258
244
  },
259
245
  "ko": {
260
246
  "cmd.git_log": "Git 로그",
@@ -289,9 +275,7 @@
289
275
  "status.move_to_diff": "커서를 diff 줄로 이동하세요",
290
276
 
291
277
  "panel.commits_header": "커밋:",
292
- "panel.no_commits": " 커밋을 찾을 수 없습니다",
293
- "panel.log_footer": "%{count}개 커밋 | 위/아래/j/k: 탐색 | RET: 표시 | y: 해시 복사 | r: 새로고침 | q: 종료",
294
- "panel.detail_footer": "위/아래/j/k: 탐색 | RET: 해당 줄에서 파일 열기 | q: 로그로 돌아가기"
278
+ "panel.no_commits": " 커밋을 찾을 수 없습니다"
295
279
  },
296
280
  "pt-BR": {
297
281
  "cmd.git_log": "Git Log",
@@ -326,9 +310,7 @@
326
310
  "status.move_to_diff": "Mova o cursor para uma linha de diff",
327
311
 
328
312
  "panel.commits_header": "Commits:",
329
- "panel.no_commits": " Nenhum commit encontrado",
330
- "panel.log_footer": "%{count} commits | Cima/Baixo/j/k: navegar | RET: mostrar | y: copiar hash | r: atualizar | q: sair",
331
- "panel.detail_footer": "Cima/Baixo/j/k: navegar | RET: abrir arquivo na linha | q: voltar ao log"
313
+ "panel.no_commits": " Nenhum commit encontrado"
332
314
  },
333
315
  "ru": {
334
316
  "cmd.git_log": "Git Log",
@@ -363,9 +345,7 @@
363
345
  "status.move_to_diff": "Peremesstite kursor na stroku diff",
364
346
 
365
347
  "panel.commits_header": "Kommity:",
366
- "panel.no_commits": " Kommity ne naydeny",
367
- "panel.log_footer": "%{count} kommitov | Vverkh/Vniz/j/k: navigatsiya | RET: pokazat' | y: kopirovat' khesh | r: obnovit' | q: vyyti",
368
- "panel.detail_footer": "Vverkh/Vniz/j/k: navigatsiya | RET: otkryt' fayl na stroke | q: nazad k logu"
348
+ "panel.no_commits": " Kommity ne naydeny"
369
349
  },
370
350
  "th": {
371
351
  "cmd.git_log": "Git Log",
@@ -400,9 +380,7 @@
400
380
  "status.move_to_diff": "เลื่อนเคอร์เซอร์ไปที่บรรทัด diff",
401
381
 
402
382
  "panel.commits_header": "คอมมิต:",
403
- "panel.no_commits": " ไม่พบคอมมิต",
404
- "panel.log_footer": "%{count} คอมมิต | ขึ้น/ลง/j/k: นำทาง | RET: แสดง | y: คัดลอกแฮช | r: รีเฟรช | q: ออก",
405
- "panel.detail_footer": "ขึ้น/ลง/j/k: นำทาง | RET: เปิดไฟล์ที่บรรทัด | q: กลับไปที่ log"
383
+ "panel.no_commits": " ไม่พบคอมมิต"
406
384
  },
407
385
  "uk": {
408
386
  "cmd.git_log": "Git Log",
@@ -437,9 +415,7 @@
437
415
  "status.move_to_diff": "Peremistit' kursor na ryadok diff",
438
416
 
439
417
  "panel.commits_header": "Komity:",
440
- "panel.no_commits": " Komity ne znaydeno",
441
- "panel.log_footer": "%{count} komitiv | Vhoru/Vnyz/j/k: navihatsiya | RET: pokazaty | y: kopiyuvaty khesh | r: onovyty | q: vyyty",
442
- "panel.detail_footer": "Vhoru/Vnyz/j/k: navihatsiya | RET: vidkryty fayl na ryadku | q: nazad do lohu"
418
+ "panel.no_commits": " Komity ne znaydeno"
443
419
  },
444
420
  "vi": {
445
421
  "cmd.git_log": "Git Log",
@@ -474,9 +450,7 @@
474
450
  "status.move_to_diff": "Di chuyển con trỏ đến dòng diff",
475
451
 
476
452
  "panel.commits_header": "Commit:",
477
- "panel.no_commits": " Không tìm thấy commit",
478
- "panel.log_footer": "%{count} commit | Lên/Xuống/j/k: điều hướng | RET: hiển thị | y: sao chép hash | r: làm mới | q: thoát",
479
- "panel.detail_footer": "Lên/Xuống/j/k: điều hướng | RET: mở tệp tại dòng | q: quay lại log"
453
+ "panel.no_commits": " Không tìm thấy commit"
480
454
  },
481
455
  "zh-CN": {
482
456
  "cmd.git_log": "Git日志",
@@ -511,8 +485,6 @@
511
485
  "status.move_to_diff": "请将光标移动到差异行",
512
486
 
513
487
  "panel.commits_header": "提交:",
514
- "panel.no_commits": " 未找到提交",
515
- "panel.log_footer": "%{count}个提交 | 上/下/j/k: 导航 | RET: 显示 | y: 复制哈希 | r: 刷新 | q: 退出",
516
- "panel.detail_footer": "上/下/j/k: 导航 | RET: 在行处打开文件 | q: 返回日志"
488
+ "panel.no_commits": " 未找到提交"
517
489
  }
518
490
  }
@@ -686,13 +686,23 @@ async function git_log_detail_open_file(): Promise<void> {
686
686
  registerHandler("git_log_detail_open_file", git_log_detail_open_file);
687
687
 
688
688
  // File-view mode so `q` closes the tab and returns to the group.
689
+ //
690
+ // j/k alias Up/Down as in the main git-log mode, and we inherit Normal
691
+ // bindings so arrows, PageUp/Down, Home/End, Ctrl+C copy, etc. still work
692
+ // in this read-only buffer — without `inheritNormalBindings`, unbound keys
693
+ // in a read-only mode fall through to the edit actions and trip the
694
+ // `editing_disabled` status message (see #566).
689
695
  editor.defineMode(
690
696
  "git-log-file-view",
691
697
  [
698
+ ["k", "move_up"],
699
+ ["j", "move_down"],
692
700
  ["q", "git_log_file_view_close"],
693
701
  ["Escape", "git_log_file_view_close"],
694
702
  ],
695
- true
703
+ true, // read-only
704
+ false, // allow_text_input
705
+ true, // inherit Normal-context bindings for unbound keys
696
706
  );
697
707
 
698
708
  function git_log_file_view_close(): void {
@@ -70,6 +70,35 @@ interface MouseClickHookArgs {
70
70
  /** 0-indexed byte column inside the buffer row. */
71
71
  buffer_col: number | null;
72
72
  }
73
+ /**
74
+ * Registry of typed plugin APIs surfaced through
75
+ * `editor.exportPluginApi` / `editor.getPluginApi`.
76
+ *
77
+ * Plugins that want their surface to be typed for downstream
78
+ * consumers augment this interface in their own source:
79
+ *
80
+ * ```ts
81
+ * // in my_plugin.ts
82
+ * export type MyPluginApi = { doThing(): void };
83
+ * declare global {
84
+ * interface FreshPluginRegistry {
85
+ * "my-plugin": MyPluginApi;
86
+ * }
87
+ * }
88
+ * ```
89
+ *
90
+ * `editor.getPluginApi("my-plugin")` then returns
91
+ * `MyPluginApi | null` without any `as`-cast on the consumer side.
92
+ * Plugins that skip the augmentation still work — the untyped
93
+ * `getPluginApi<T = unknown>(name: string): T | null` overload
94
+ * takes over.
95
+ *
96
+ * Each plugin's augmentation is emitted to
97
+ * `<config_dir>/types/plugins.d.ts` at load time (via oxc's
98
+ * isolated-declarations), so init.ts sees every loaded plugin's
99
+ * registry entry automatically.
100
+ */
101
+ interface FreshPluginRegistry {}
73
102
  type TextPropertyEntry = {
74
103
  /**
75
104
  * Text content for this entry
@@ -343,6 +372,15 @@ type BufferInfo = {
343
372
  * refreshing itself for a preview tab.
344
373
  */
345
374
  is_preview: boolean;
375
+ /**
376
+ * Split ids that currently hold this buffer (empty when the buffer is
377
+ * open but not visible in any split — e.g. background-opened tabs
378
+ * that haven't been focused). Lets plugins implement "focus existing
379
+ * buffer if visible, else open new" without having to track split
380
+ * ids across editor restarts (which reassign them). The list is a
381
+ * snapshot at the last `update_plugin_state_snapshot` tick.
382
+ */
383
+ splits: number[];
346
384
  };
347
385
  type JsDiagnostic = {
348
386
  /**
@@ -497,6 +535,15 @@ type CreateTerminalOptions = {
497
535
  * Whether to focus the new terminal split (default: true)
498
536
  */
499
537
  focus?: boolean;
538
+ /**
539
+ * Whether this terminal is part of the user's persisted workspace.
540
+ * Defaults to `false` for plugin-created terminals — they are typically
541
+ * one-off tool UIs (rebuilds, exec shells, build output) and should
542
+ * start with empty scrollback on each invocation. Set to `true` only
543
+ * when the plugin owns a terminal that the user should see restored
544
+ * across editor restarts.
545
+ */
546
+ persistent?: boolean;
500
547
  };
501
548
  type CursorInfo = {
502
549
  /**
@@ -584,6 +631,31 @@ type GrammarInfoSnapshot = {
584
631
  */
585
632
  short_name: string | null;
586
633
  };
634
+ type AuthorityFilesystem = {
635
+ kind: "local";
636
+ };
637
+ type AuthoritySpawner = {
638
+ kind: "local";
639
+ } | {
640
+ kind: "docker-exec";
641
+ container_id: string;
642
+ user?: string | null;
643
+ workspace?: string | null;
644
+ };
645
+ type AuthorityTerminalWrapper = {
646
+ kind: "host-shell";
647
+ } | {
648
+ kind: "explicit";
649
+ command: string;
650
+ args: string[];
651
+ manages_cwd?: boolean;
652
+ };
653
+ type AuthorityPayload = {
654
+ filesystem: AuthorityFilesystem;
655
+ spawner: AuthoritySpawner;
656
+ terminal_wrapper: AuthorityTerminalWrapper;
657
+ display_label?: string;
658
+ };
587
659
  type BackgroundProcessResult = {
588
660
  /**
589
661
  * Unique process ID for later reference
@@ -808,6 +880,21 @@ type LspServerPackConfig = {
808
880
  */
809
881
  processLimits: ProcessLimitsPackConfig | null;
810
882
  };
883
+ type RemoteIndicatorStatePayload = {
884
+ kind: "local";
885
+ } | {
886
+ kind: "connecting";
887
+ label?: string | null;
888
+ } | {
889
+ kind: "connected";
890
+ label?: string | null;
891
+ } | {
892
+ kind: "failed_attach";
893
+ error?: string | null;
894
+ } | {
895
+ kind: "disconnected";
896
+ label?: string | null;
897
+ };
811
898
  type ReplaceResult = {
812
899
  /**
813
900
  * Number of replacements made
@@ -860,6 +947,30 @@ interface EditorAPI {
860
947
  */
861
948
  apiVersion(): number;
862
949
  /**
950
+ * The name of the plugin this `editor` handle belongs to. Used by the
951
+ * M3 plugin-API plane (`exportPluginApi` tags the exporter). Plugin
952
+ * authors generally don't call this directly.
953
+ */
954
+ pluginName(): string;
955
+ /**
956
+ * Publish a typed API surface under `name`. Another plugin (typically
957
+ * `init.ts`) can reach it later via `getPluginApi(name)`. Calling
958
+ * again with the same `name` replaces the previous registration
959
+ * (idempotent — reload works). Exports are auto-dropped when the
960
+ * calling plugin is unloaded.
961
+ *
962
+ * Returns `true` on success. Rejects with a TypeError if `name` is
963
+ * empty or `api` is not an object (functions and primitives are not
964
+ * valid API surfaces — only objects).
965
+ */
966
+ exportPluginApi(name: string, api: unknown): boolean;
967
+ /**
968
+ * Look up a plugin API previously published via `exportPluginApi`.
969
+ * Returns the api object (restored into the caller's context) or
970
+ * `null` if no plugin exports under that name.
971
+ */
972
+ getPluginApi(name: string): unknown | null;
973
+ /**
863
974
  * Get the active buffer ID (0 if none)
864
975
  */
865
976
  getActiveBufferId(): number;
@@ -1045,8 +1156,26 @@ interface EditorAPI {
1045
1156
  */
1046
1157
  getCwd(): string;
1047
1158
  /**
1159
+ * Get the active authority's display label.
1160
+ *
1161
+ * Empty means the local (default) authority. A non-empty value
1162
+ * means a plugin-installed or SSH authority is in effect (e.g.
1163
+ * `"Container:abc123def456"` for a devcontainer). Intended as a
1164
+ * simple "am I already attached?" check that survives editor
1165
+ * restarts — the label lives on the `Editor` state snapshot so it
1166
+ * is fresh after the authority-transition restart flow.
1167
+ */
1168
+ getAuthorityLabel(): string;
1169
+ /**
1048
1170
  * Join path components (variadic - accepts multiple string arguments)
1049
1171
  * Always uses forward slashes for cross-platform consistency (like Node.js path.posix.join)
1172
+ *
1173
+ * Preserves up to 2 leading slashes, which matters on Windows: Rust's
1174
+ * `Path::canonicalize` returns `\\?\`-prefixed paths, and `editor.getCwd()`
1175
+ * surfaces that to plugin code verbatim. After the backslash→slash
1176
+ * normalization the prefix becomes `//?/C:/...`; collapsing the leading
1177
+ * `//` to a single `/` yields `/?/C:/...`, which every filesystem API on
1178
+ * Windows rejects, breaking `findConfig()`-style plugin logic.
1050
1179
  */
1051
1180
  pathJoin(...parts: string[]): string;
1052
1181
  /**
@@ -1129,11 +1258,27 @@ interface EditorAPI {
1129
1258
  */
1130
1259
  getTempDir(): string;
1131
1260
  /**
1132
- * Get current config as JS object
1261
+ * Parse a JSONC (JSON with comments) string into a JS value.
1262
+ *
1263
+ * Accepts the JSONC superset: line and block comments, trailing
1264
+ * commas, single-quoted strings, and unquoted object keys — matching
1265
+ * devcontainer.json / tsconfig.json / VS Code settings.json.
1266
+ *
1267
+ * Throws a JS error (catchable with try/catch) when the input is not
1268
+ * valid JSONC, like `JSON.parse` does for invalid JSON.
1269
+ */
1270
+ parseJsonc(text: string): unknown;
1271
+ /**
1272
+ * Get current config as JS object.
1273
+ *
1274
+ * The snapshot holds an `Arc<serde_json::Value>` that was serialized
1275
+ * on the editor side the last time the underlying `Arc<Config>`
1276
+ * changed. Cloning the Arc inside the read lock is a refcount bump;
1277
+ * the actual walk into the JS runtime happens outside the lock.
1133
1278
  */
1134
1279
  getConfig(): unknown;
1135
1280
  /**
1136
- * Get user config as JS object
1281
+ * Get user config as JS object. Same Arc-clone pattern as `get_config`.
1137
1282
  */
1138
1283
  getUserConfig(): unknown;
1139
1284
  /**
@@ -1141,6 +1286,21 @@ interface EditorAPI {
1141
1286
  */
1142
1287
  reloadConfig(): void;
1143
1288
  /**
1289
+ * Set a single config setting in the runtime layer for this session.
1290
+ *
1291
+ * `path` is dot-separated (e.g. `"editor.tab_size"`). `value` is any JSON
1292
+ * value in the shape the setting expects. The write lives in an
1293
+ * in-memory layer scoped to the calling plugin — it does not modify
1294
+ * `config.json`, and unloading the plugin (or reloading init.ts) drops
1295
+ * it. Intended use is `init.ts` running a conditional:
1296
+ * `if (editor.getEnv("SSH_TTY")) editor.setSetting("terminal.mouse", false);`
1297
+ *
1298
+ * Returns `true` if the write was queued. The actual update is
1299
+ * asynchronous; a subsequent `getConfig()` will reflect it after the
1300
+ * editor processes the command.
1301
+ */
1302
+ setSetting(path: string, value: unknown): boolean;
1303
+ /**
1144
1304
  * Reload theme registry from disk
1145
1305
  * Call this after installing theme packages or saving new themes
1146
1306
  */
@@ -1192,6 +1352,17 @@ interface EditorAPI {
1192
1352
  */
1193
1353
  applyTheme(themeName: string): boolean;
1194
1354
  /**
1355
+ * Override theme colors in-memory for the running session. `overrides`
1356
+ * is a JS object mapping `"section.field"` keys (same namespace as
1357
+ * `getThemeSchema`) to `[r, g, b]` triplets (0–255 each).
1358
+ *
1359
+ * Unknown keys are dropped silently; out-of-range values are clamped
1360
+ * to `0..=255`. Overrides survive until the next `applyTheme` call
1361
+ * (which replaces the whole `Theme`). Intended for fast animation
1362
+ * loops from `init.ts` — no disk I/O, no theme-registry rescan.
1363
+ */
1364
+ overrideThemeColors(overrides: unknown): boolean;
1365
+ /**
1195
1366
  * Get theme schema as JS object
1196
1367
  */
1197
1368
  getThemeSchema(): unknown;
@@ -1605,6 +1776,56 @@ interface EditorAPI {
1605
1776
  */
1606
1777
  spawnProcess(command: string, args: string[], cwd?: string): ProcessHandle<SpawnResult>;
1607
1778
  /**
1779
+ * Spawn a process on the host regardless of the active authority.
1780
+ *
1781
+ * Intended for plugin internals that must run host-side work
1782
+ * (e.g. `devcontainer up`) before installing an authority that
1783
+ * would otherwise route the spawn elsewhere. Same calling shape
1784
+ * as `spawnProcess`.
1785
+ */
1786
+ spawnHostProcess(command: string, args: string[], cwd?: string): ProcessHandle<SpawnResult>;
1787
+ /**
1788
+ * Install a new authority via an opaque payload.
1789
+ *
1790
+ * The payload is a JS object describing filesystem + spawner +
1791
+ * terminal wrapper + display label. The canonical schema lives in
1792
+ * the `AuthorityPayload` type in `fresh-editor`; plugins should
1793
+ * hand-build objects that match it. Fire-and-forget: the editor
1794
+ * restarts as part of the transition, so the plugin is reloaded
1795
+ * before any follow-up work can run on this call's return value.
1796
+ */
1797
+ setAuthority(payload: AuthorityPayload): boolean;
1798
+ /**
1799
+ * Restore the default local authority. Same restart semantics as
1800
+ * `setAuthority`.
1801
+ */
1802
+ clearAuthority(): void;
1803
+ /**
1804
+ * Override the Remote Indicator's displayed state. Plugins call
1805
+ * this to surface lifecycle transitions that the authority layer
1806
+ * doesn't know about yet — "Connecting" while `devcontainer up`
1807
+ * runs, "FailedAttach" after a non-zero exit, etc.
1808
+ *
1809
+ * Accepts a tagged JS object:
1810
+ * ```ts
1811
+ * editor.setRemoteIndicatorState({ kind: "connecting", label: "Building" });
1812
+ * editor.setRemoteIndicatorState({ kind: "failed_attach", error: "exit 1" });
1813
+ * editor.setRemoteIndicatorState({ kind: "connected", label: "Container:abc" });
1814
+ * editor.setRemoteIndicatorState({ kind: "local" });
1815
+ * ```
1816
+ *
1817
+ * The override sticks until replaced or cleared via
1818
+ * `clearRemoteIndicatorState`. Editor restart (e.g. on
1819
+ * `setAuthority`) resets it — plugins must reassert after a
1820
+ * post-restart init if they want the override to persist.
1821
+ */
1822
+ setRemoteIndicatorState(state: RemoteIndicatorStatePayload): boolean;
1823
+ /**
1824
+ * Drop any active Remote Indicator override. Safe to call even
1825
+ * without a prior `setRemoteIndicatorState`.
1826
+ */
1827
+ clearRemoteIndicatorState(): void;
1828
+ /**
1608
1829
  * Wait for a process to complete and get its result (async)
1609
1830
  */
1610
1831
  spawnProcessWait(processId: number): Promise<SpawnResult>;
@@ -1695,3 +1916,12 @@ interface EditorAPI {
1695
1916
  enabled: boolean;
1696
1917
  }>>;
1697
1918
  }
1919
+ /**
1920
+ * Typed overload of `editor.getPluginApi`. When the caller passes a
1921
+ * key that some loaded plugin declared in `FreshPluginRegistry`, the
1922
+ * return type is narrowed to that plugin's API. Unknown names fall
1923
+ * through to the untyped `unknown | null` signature.
1924
+ */
1925
+ interface EditorAPI {
1926
+ getPluginApi<K extends keyof FreshPluginRegistry>(name: K): FreshPluginRegistry[K] | null;
1927
+ }
@@ -966,6 +966,23 @@ function concealedText(text: string): string {
966
966
 
967
967
  const MIN_COL_W = 3;
968
968
 
969
+ /**
970
+ * Return the effective compose width for layout: the configured compose
971
+ * width clamped to the available viewport width.
972
+ *
973
+ * When `config.composeWidth` is explicitly set (e.g. 80) but the editor
974
+ * content area is smaller (e.g. after the File Explorer sidebar opens),
975
+ * using the configured value verbatim overflows the viewport. The Rust
976
+ * render layer already clamps the compose area the same way in
977
+ * `calculate_compose_layout`; plugin-side computations (table column
978
+ * allocation, soft-wrap width) need to match.
979
+ */
980
+ function effectiveComposeWidth(viewportWidth: number): number {
981
+ const cw = config.composeWidth;
982
+ if (cw == null) return viewportWidth;
983
+ return Math.min(cw, viewportWidth);
984
+ }
985
+
969
986
  /**
970
987
  * W3C-inspired column width distribution.
971
988
  * Constrains columns to fit within `available` width, distributing space
@@ -1142,6 +1159,26 @@ function processLineConceals(
1142
1159
  if (lineContent[i] === '|') pipePositions.push(i);
1143
1160
  }
1144
1161
 
1162
+ // Precompute which cells will be truncated. Per-character conceals
1163
+ // that land inside a truncated cell must be suppressed — the cell-
1164
+ // wide truncate conceal already renders the replacement. When both
1165
+ // fire, the per-char conceal at the cell's first byte emits its
1166
+ // replacement, and the cell-wide conceal emits its replacement one
1167
+ // byte later, producing a cell one character wider than allocated.
1168
+ const truncatedCellCharRanges: Array<{start: number; end: number}> = [];
1169
+ if (!cursorStrictlyOnLine && colWidths) {
1170
+ for (let ci = 0; ci < Math.min(cells.length, colWidths.length); ci++) {
1171
+ const cellText = concealedText(cells[ci]);
1172
+ if (cellText.length > colWidths[ci]) {
1173
+ const prevPipe = pipePositions[ci];
1174
+ const nextPipe = pipePositions[ci + 1];
1175
+ if (prevPipe !== undefined && nextPipe !== undefined) {
1176
+ truncatedCellCharRanges.push({ start: prevPipe + 1, end: nextPipe });
1177
+ }
1178
+ }
1179
+ }
1180
+ }
1181
+
1145
1182
  // Track which pipe index we're on (0 = leading pipe)
1146
1183
  let pipeIdx = 0;
1147
1184
  for (let i = 0; i < lineContent.length; i++) {
@@ -1161,11 +1198,15 @@ function processLineConceals(
1161
1198
  const allocatedWidth = colWidths[cellIdx];
1162
1199
 
1163
1200
  if (cellWidth > allocatedWidth) {
1164
- // Truncate: conceal entire cell content and replace with truncated text
1201
+ // Truncate: conceal entire cell content and replace with truncated text.
1202
+ // Separator rows use box-drawing ─ to match the non-truncated path
1203
+ // (per-char conceals replace source `-` with ─ and pad via pipe replacement).
1165
1204
  const prevPipeCharPos = pipePositions[pipeIdx - 1];
1166
1205
  const cellByteStart = charToByte(lineContent, prevPipeCharPos + 1, byteStart);
1167
1206
  const cellByteEnd = pipeByte;
1168
- const truncated = cellText.slice(0, allocatedWidth - 1) + '-';
1207
+ const truncated = isSeparator
1208
+ ? '─'.repeat(allocatedWidth)
1209
+ : cellText.slice(0, allocatedWidth - 1) + '-';
1169
1210
  editor.addConceal(bufferId, "md-syntax", cellByteStart, cellByteEnd, truncated);
1170
1211
  truncatedByteRanges.push({start: cellByteStart, end: cellByteEnd});
1171
1212
  } else {
@@ -1188,6 +1229,10 @@ function processLineConceals(
1188
1229
  }
1189
1230
  pipeIdx++;
1190
1231
  } else if (isSeparator && lineContent[i] === '-') {
1232
+ // Skip per-character conceals that land inside a truncated cell;
1233
+ // the cell-wide truncate conceal already handles the rendering.
1234
+ const inTruncated = truncatedCellCharRanges.some(r => i >= r.start && i < r.end);
1235
+ if (inTruncated) continue;
1191
1236
  const db = charToByte(lineContent, i, byteStart);
1192
1237
  editor.addConceal(bufferId, "md-syntax", db, charToByte(lineContent, i + 1, byteStart), "─");
1193
1238
  }
@@ -1292,7 +1337,7 @@ function processLineSoftBreaks(
1292
1337
 
1293
1338
  const viewport = editor.getViewport();
1294
1339
  if (!viewport) return;
1295
- const width = config.composeWidth ?? viewport.width;
1340
+ const width = effectiveComposeWidth(viewport.width);
1296
1341
 
1297
1342
  // Parse this single line to get block structure
1298
1343
  const blocks = parseMarkdownBlocks(lineContent);
@@ -1516,9 +1561,12 @@ function processTableAlignment(
1516
1561
  mergeWith(widthMap.get(ln)!.maxW);
1517
1562
  }
1518
1563
 
1519
- // Compute allocated widths constrained to viewport
1564
+ // Compute allocated widths constrained to viewport. Clamp the
1565
+ // configured compose width to the actual viewport — otherwise a
1566
+ // large configured width overflows when the editor area shrinks
1567
+ // (e.g. when the File Explorer sidebar opens).
1520
1568
  const viewport = editor.getViewport();
1521
- const composeW = config.composeWidth ?? (viewport ? viewport.width : 80);
1569
+ const composeW = effectiveComposeWidth(viewport ? viewport.width : 80);
1522
1570
  const numCols = merged.length;
1523
1571
  const available = composeW - (numCols + 1); // subtract pipe/box-drawing characters
1524
1572
  const allocated = distributeColumnWidths(merged, available);
@@ -1688,7 +1736,7 @@ function onMarkdownViewportChanged(data: {
1688
1736
  // Recompute allocated table column widths for new viewport width
1689
1737
  const bufWidths = getTableWidths(data.buffer_id);
1690
1738
  if (bufWidths) {
1691
- const composeW = config.composeWidth ?? data.width;
1739
+ const composeW = effectiveComposeWidth(data.width);
1692
1740
  const seen = new Set<string>(); // Track by JSON key to deduplicate shared TableWidthInfo
1693
1741
  for (const [lineNum, info] of bufWidths) {
1694
1742
  const key = info.maxW.join(",");