dsh-ide-git 0.20.0 → 0.21.1

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.
package/dsh.plugin.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "dsh-external/dsh-ide-git",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "main": "./src/index.js",
5
5
  "description": "IDE 级 Git 操作面板:分支树 + 提交图谱 + 变更/暂存/提交 + 提交详情。装了 dsh-better-sidebar 时挂成它的原生 tab(右侧栏 + 底部工作台);没装时直接挂 DSH 原生右侧栏,单独安装即可用。JetBrains 风格 Git 工具窗口 for DeepSeek Harness.",
6
6
  "engines": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-ide-git",
3
- "version": "0.20.0",
3
+ "version": "0.21.1",
4
4
  "description": "DSH plugin: an IDE-grade Git tool window as a native dsh-better-sidebar tab — branch tree, commit graph, changes, commit details, JetBrains-style actions, in the right sidebar and the bottom panel. | DSH 插件:IDE 级 Git 工具窗口,以 dsh-better-sidebar 原生 Tab 挂载——分支树 / 提交图谱 / 变更与提交详情 / JetBrains 风格操作,右侧栏与底部面板双布局自适应。",
5
5
  "type": "module",
6
6
  "repository": {
package/src/client.js CHANGED
@@ -302,6 +302,12 @@ window.__ModuleLoader__.load({
302
302
  'composer.changesHint': '未提交变更数',
303
303
  'composer.fileHint': '新增 {a} · 删除 {d} 个文件',
304
304
  'composer.linesHint': '+{ins} −{del} 行',
305
+ 'modelRoute.pick': '选择模型',
306
+ 'modelRoute.trigger': 'AI 提交信息模型',
307
+ 'modelRoute.modelRow': '模型',
308
+ 'modelRoute.effortRow': '思考',
309
+ 'modelRoute.effortDefault': '跟随默认',
310
+ 'modelRoute.empty': '暂无可用模型',
305
311
  'composer.behindHint': '远程新提交(可拉取)',
306
312
  'composer.aheadHint': '本地待推送提交',
307
313
  'composer.inWorktree': '已被其他工作树签出',
@@ -638,6 +644,12 @@ window.__ModuleLoader__.load({
638
644
  'composer.changesHint': 'Uncommitted changes',
639
645
  'composer.fileHint': '{a} added · {d} deleted files',
640
646
  'composer.linesHint': '+{ins} −{del} lines',
647
+ 'modelRoute.pick': 'Pick a model',
648
+ 'modelRoute.trigger': 'AI commit message model',
649
+ 'modelRoute.modelRow': 'Model',
650
+ 'modelRoute.effortRow': 'Reasoning',
651
+ 'modelRoute.effortDefault': 'Follow default',
652
+ 'modelRoute.empty': 'No models available',
641
653
  'composer.behindHint': 'New upstream commits (pullable)',
642
654
  'composer.aheadHint': 'Local commits waiting to push',
643
655
  'composer.inWorktree': 'Checked out in another worktree',
@@ -982,6 +994,12 @@ window.__ModuleLoader__.load({
982
994
  'composer.changesHint': '未提交變更數',
983
995
  'composer.fileHint': '新增 {a} · 刪除 {d} 個檔案',
984
996
  'composer.linesHint': '+{ins} −{del} 行',
997
+ 'modelRoute.pick': '選擇模型',
998
+ 'modelRoute.trigger': 'AI 提交訊息模型',
999
+ 'modelRoute.modelRow': '模型',
1000
+ 'modelRoute.effortRow': '思考',
1001
+ 'modelRoute.effortDefault': '跟隨預設',
1002
+ 'modelRoute.empty': '暫無可用模型',
985
1003
  'composer.behindHint': '遠端新提交(可以拉取)',
986
1004
  'composer.aheadHint': '本地等待推送提交',
987
1005
  'composer.inWorktree': '已經喺其他工作樹簽出',
@@ -1318,6 +1336,12 @@ window.__ModuleLoader__.load({
1318
1336
  'composer.changesHint': '未提交變更數',
1319
1337
  'composer.fileHint': '新增 {a} · 刪除 {d} 個檔案',
1320
1338
  'composer.linesHint': '+{ins} −{del} 行',
1339
+ 'modelRoute.pick': '選擇模型',
1340
+ 'modelRoute.trigger': 'AI 提交訊息模型',
1341
+ 'modelRoute.modelRow': '模型',
1342
+ 'modelRoute.effortRow': '思考',
1343
+ 'modelRoute.effortDefault': '跟隨預設',
1344
+ 'modelRoute.empty': '暫無可用模型',
1321
1345
  'composer.behindHint': '遠端新提交(可拉取)',
1322
1346
  'composer.aheadHint': '本地待推送提交',
1323
1347
  'composer.inWorktree': '已被其他工作樹簽出',
@@ -1654,6 +1678,12 @@ window.__ModuleLoader__.load({
1654
1678
  'composer.changesHint': '未提交變更數',
1655
1679
  'composer.fileHint': '新增 {a} · 刪除 {d} 個檔案',
1656
1680
  'composer.linesHint': '+{ins} −{del} 行',
1681
+ 'modelRoute.pick': '選擇模型',
1682
+ 'modelRoute.trigger': 'AI 提交訊息模型',
1683
+ 'modelRoute.modelRow': '模型',
1684
+ 'modelRoute.effortRow': '思考',
1685
+ 'modelRoute.effortDefault': '跟隨預設',
1686
+ 'modelRoute.empty': '暫無可用模型',
1657
1687
  'composer.behindHint': '遠端新提交(可以拉取)',
1658
1688
  'composer.aheadHint': '本地等待推送提交',
1659
1689
  'composer.inWorktree': '已經喺其他工作樹簽出',
@@ -1990,6 +2020,12 @@ window.__ModuleLoader__.load({
1990
2020
  'composer.changesHint': '未コミットの変更数',
1991
2021
  'composer.fileHint': '追加 {a} · 削除 {d} ファイル',
1992
2022
  'composer.linesHint': '+{ins} −{del} 行',
2023
+ 'modelRoute.pick': 'モデルを選択',
2024
+ 'modelRoute.trigger': 'AI コミットメッセージのモデル',
2025
+ 'modelRoute.modelRow': 'モデル',
2026
+ 'modelRoute.effortRow': '思考',
2027
+ 'modelRoute.effortDefault': 'デフォルトに従う',
2028
+ 'modelRoute.empty': '利用可能なモデルがありません',
1993
2029
  'composer.behindHint': 'リモートの新規コミット(取得可能)',
1994
2030
  'composer.aheadHint': 'プッシュ待ちのローカルコミット',
1995
2031
  'composer.inWorktree': '別のワークツリーでチェックアウト中',
@@ -2326,6 +2362,12 @@ window.__ModuleLoader__.load({
2326
2362
  'composer.changesHint': '커밋되지 않은 변경 수',
2327
2363
  'composer.fileHint': '추가 {a} · 삭제 {d}개 파일',
2328
2364
  'composer.linesHint': '+{ins} −{del}줄',
2365
+ 'modelRoute.pick': '모델 선택',
2366
+ 'modelRoute.trigger': 'AI 커밋 메시지 모델',
2367
+ 'modelRoute.modelRow': '모델',
2368
+ 'modelRoute.effortRow': '추론',
2369
+ 'modelRoute.effortDefault': '기본값 따르기',
2370
+ 'modelRoute.empty': '사용 가능한 모델이 없습니다',
2329
2371
  'composer.behindHint': '원격 새 커밋(가져오기 가능)',
2330
2372
  'composer.aheadHint': '푸시 대기 중인 로컬 커밋',
2331
2373
  'composer.inWorktree': '다른 작업 트리에서 체크아웃됨',
@@ -2662,6 +2704,12 @@ window.__ModuleLoader__.load({
2662
2704
  'composer.changesHint': 'Uncommittete Änderungen',
2663
2705
  'composer.fileHint': '{a} hinzugefügt · {d} gelöscht',
2664
2706
  'composer.linesHint': '+{ins} −{del} Zeilen',
2707
+ 'modelRoute.pick': 'Modell wählen',
2708
+ 'modelRoute.trigger': 'Modell für KI-Commits',
2709
+ 'modelRoute.modelRow': 'Modell',
2710
+ 'modelRoute.effortRow': 'Denken',
2711
+ 'modelRoute.effortDefault': 'Standard folgen',
2712
+ 'modelRoute.empty': 'Keine Modelle verfügbar',
2665
2713
  'composer.behindHint': 'Neue Upstream-Commits (abrufbar)',
2666
2714
  'composer.aheadHint': 'Lokale Commits zum Pushen',
2667
2715
  'composer.inWorktree': 'In anderem Worktree ausgecheckt',
@@ -2998,6 +3046,12 @@ window.__ModuleLoader__.load({
2998
3046
  'composer.changesHint': 'Modifications non commitées',
2999
3047
  'composer.fileHint': '{a} ajoutés · {d} supprimés',
3000
3048
  'composer.linesHint': '+{ins} −{del} lignes',
3049
+ 'modelRoute.pick': 'Choisir un modèle',
3050
+ 'modelRoute.trigger': 'Modèle des messages de commit IA',
3051
+ 'modelRoute.modelRow': 'Modèle',
3052
+ 'modelRoute.effortRow': 'Raisonnement',
3053
+ 'modelRoute.effortDefault': 'Suivre la valeur par défaut',
3054
+ 'modelRoute.empty': 'Aucun modèle disponible',
3001
3055
  'composer.behindHint': 'Nouveaux commits amont (récupérables)',
3002
3056
  'composer.aheadHint': 'Commits locaux en attente d’envoi',
3003
3057
  'composer.inWorktree': 'Extrait dans un autre arbre de travail',
@@ -3334,6 +3388,12 @@ window.__ModuleLoader__.load({
3334
3388
  'composer.changesHint': 'Незакоммиченные изменения',
3335
3389
  'composer.fileHint': 'добавлено: {a} · удалено: {d}',
3336
3390
  'composer.linesHint': '+{ins} −{del} строк',
3391
+ 'modelRoute.pick': 'Выбрать модель',
3392
+ 'modelRoute.trigger': 'Модель сообщений коммитов ИИ',
3393
+ 'modelRoute.modelRow': 'Модель',
3394
+ 'modelRoute.effortRow': 'Рассуждение',
3395
+ 'modelRoute.effortDefault': 'По умолчанию',
3396
+ 'modelRoute.empty': 'Нет доступных моделей',
3337
3397
  'composer.behindHint': 'Новые коммиты upstream (можно забрать)',
3338
3398
  'composer.aheadHint': 'Локальные коммиты, ожидающие отправки',
3339
3399
  'composer.inWorktree': 'Выверен в другом worktree',
@@ -3670,6 +3730,12 @@ window.__ModuleLoader__.load({
3670
3730
  'composer.changesHint': 'Alterações não commitadas',
3671
3731
  'composer.fileHint': '{a} adicionados · {d} removidos',
3672
3732
  'composer.linesHint': '+{ins} −{del} linhas',
3733
+ 'modelRoute.pick': 'Escolher modelo',
3734
+ 'modelRoute.trigger': 'Modelo da mensagem de commit por IA',
3735
+ 'modelRoute.modelRow': 'Modelo',
3736
+ 'modelRoute.effortRow': 'Raciocínio',
3737
+ 'modelRoute.effortDefault': 'Seguir o padrão',
3738
+ 'modelRoute.empty': 'Nenhum modelo disponível',
3673
3739
  'composer.behindHint': 'Novos commits remotos (para pull)',
3674
3740
  'composer.aheadHint': 'Commits locais aguardando push',
3675
3741
  'composer.inWorktree': 'Em checkout em outra worktree',
@@ -4006,6 +4072,12 @@ window.__ModuleLoader__.load({
4006
4072
  'composer.changesHint': 'Modifiche non ancora committate',
4007
4073
  'composer.fileHint': '{a} aggiunti · {d} eliminati',
4008
4074
  'composer.linesHint': '+{ins} −{del} righe',
4075
+ 'modelRoute.pick': 'Scegli modello',
4076
+ 'modelRoute.trigger': 'Modello dei messaggi di commit IA',
4077
+ 'modelRoute.modelRow': 'Modello',
4078
+ 'modelRoute.effortRow': 'Ragionamento',
4079
+ 'modelRoute.effortDefault': 'Segui il predefinito',
4080
+ 'modelRoute.empty': 'Nessun modello disponibile',
4009
4081
  'composer.behindHint': 'Nuovi commit upstream (scaricabili)',
4010
4082
  'composer.aheadHint': 'Commit locali in attesa di push',
4011
4083
  'composer.inWorktree': 'Estratto in un altro worktree',
@@ -4342,6 +4414,12 @@ window.__ModuleLoader__.load({
4342
4414
  'composer.changesHint': 'Ongecommitte wijzigingen',
4343
4415
  'composer.fileHint': '{a} toegevoegd · {d} verwijderd',
4344
4416
  'composer.linesHint': '+{ins} −{del} regels',
4417
+ 'modelRoute.pick': 'Kies een model',
4418
+ 'modelRoute.trigger': 'Model voor AI-commitberichten',
4419
+ 'modelRoute.modelRow': 'Model',
4420
+ 'modelRoute.effortRow': 'Redeneren',
4421
+ 'modelRoute.effortDefault': 'Standaard volgen',
4422
+ 'modelRoute.empty': 'Geen modellen beschikbaar',
4345
4423
  'composer.behindHint': 'Nieuwe upstream-commits (op te halen)',
4346
4424
  'composer.aheadHint': 'Lokale commits die wachten op push',
4347
4425
  'composer.inWorktree': 'Uitgecheckt in een andere worktree',
@@ -4678,6 +4756,12 @@ window.__ModuleLoader__.load({
4678
4756
  'composer.changesHint': 'Niezatwierdzone zmiany',
4679
4757
  'composer.fileHint': 'dodane: {a} · usunięte: {d}',
4680
4758
  'composer.linesHint': '+{ins} −{del} linii',
4759
+ 'modelRoute.pick': 'Wybierz model',
4760
+ 'modelRoute.trigger': 'Model wiadomości commitów AI',
4761
+ 'modelRoute.modelRow': 'Model',
4762
+ 'modelRoute.effortRow': 'Rozumowanie',
4763
+ 'modelRoute.effortDefault': 'Domyślne',
4764
+ 'modelRoute.empty': 'Brak dostępnych modeli',
4681
4765
  'composer.behindHint': 'Nowe commity upstream (do pobrania)',
4682
4766
  'composer.aheadHint': 'Lokalne commity oczekujące na wysłanie',
4683
4767
  'composer.inWorktree': 'Wybrane w innym drzewie roboczym',
@@ -5014,6 +5098,12 @@ window.__ModuleLoader__.load({
5014
5098
  'composer.changesHint': 'Ocommittade ändringar',
5015
5099
  'composer.fileHint': '{a} tillagda · {d} borttagna',
5016
5100
  'composer.linesHint': '+{ins} −{del} rader',
5101
+ 'modelRoute.pick': 'Välj modell',
5102
+ 'modelRoute.trigger': 'Modell för AI-commit-meddelanden',
5103
+ 'modelRoute.modelRow': 'Modell',
5104
+ 'modelRoute.effortRow': 'Resonemang',
5105
+ 'modelRoute.effortDefault': 'Följ standard',
5106
+ 'modelRoute.empty': 'Inga modeller tillgängliga',
5017
5107
  'composer.behindHint': 'Nya upstream-commits (hämtbara)',
5018
5108
  'composer.aheadHint': 'Lokala commits som väntar på push',
5019
5109
  'composer.inWorktree': 'Utcheckad i ett annat arbetsträd',
@@ -5350,6 +5440,12 @@ window.__ModuleLoader__.load({
5350
5440
  'composer.changesHint': 'Commitlenmemiş değişiklikler',
5351
5441
  'composer.fileHint': '{a} eklendi · {d} silindi',
5352
5442
  'composer.linesHint': '+{ins} −{del} satır',
5443
+ 'modelRoute.pick': 'Model seç',
5444
+ 'modelRoute.trigger': 'Yapay zekâ commit mesajı modeli',
5445
+ 'modelRoute.modelRow': 'Model',
5446
+ 'modelRoute.effortRow': 'Akıl yürütme',
5447
+ 'modelRoute.effortDefault': 'Varsayılanı izle',
5448
+ 'modelRoute.empty': 'Kullanılabilir model yok',
5353
5449
  'composer.behindHint': 'Yeni upstream commitleri (çekilebilir)',
5354
5450
  'composer.aheadHint': 'Push bekleyen yerel commitler',
5355
5451
  'composer.inWorktree': 'Başka bir worktree’da checkout yapılmış',
@@ -5686,6 +5782,12 @@ window.__ModuleLoader__.load({
5686
5782
  'composer.changesHint': 'Perubahan yang belum di-commit',
5687
5783
  'composer.fileHint': '{a} ditambahkan · {d} dihapus',
5688
5784
  'composer.linesHint': '+{ins} −{del} baris',
5785
+ 'modelRoute.pick': 'Pilih model',
5786
+ 'modelRoute.trigger': 'Model pesan commit AI',
5787
+ 'modelRoute.modelRow': 'Model',
5788
+ 'modelRoute.effortRow': 'Penalaran',
5789
+ 'modelRoute.effortDefault': 'Ikuti bawaan',
5790
+ 'modelRoute.empty': 'Tidak ada model yang tersedia',
5689
5791
  'composer.behindHint': 'Commit upstream baru (bisa ditarik)',
5690
5792
  'composer.aheadHint': 'Commit lokal menunggu push',
5691
5793
  'composer.inWorktree': 'Sudah di-checkout di worktree lain',
@@ -6022,6 +6124,12 @@ window.__ModuleLoader__.load({
6022
6124
  'composer.changesHint': 'Thay đổi chưa commit',
6023
6125
  'composer.fileHint': 'thêm {a} · xóa {d} tệp',
6024
6126
  'composer.linesHint': '+{ins} −{del} dòng',
6127
+ 'modelRoute.pick': 'Chọn mô hình',
6128
+ 'modelRoute.trigger': 'Mô hình tin nhắn commit AI',
6129
+ 'modelRoute.modelRow': 'Mô hình',
6130
+ 'modelRoute.effortRow': 'Suy luận',
6131
+ 'modelRoute.effortDefault': 'Theo mặc định',
6132
+ 'modelRoute.empty': 'Không có mô hình nào',
6025
6133
  'composer.behindHint': 'Commit mới từ xa (có thể kéo về)',
6026
6134
  'composer.aheadHint': 'Commit cục bộ chờ đẩy lên',
6027
6135
  'composer.inWorktree': 'Đã được checkout ở worktree khác',
@@ -6358,6 +6466,12 @@ window.__ModuleLoader__.load({
6358
6466
  'composer.changesHint': 'تغييرات غير ملتزَم بها',
6359
6467
  'composer.fileHint': 'مضاف {a} · محذوف {d}',
6360
6468
  'composer.linesHint': '+{ins} −{del} أسطر',
6469
+ 'modelRoute.pick': 'اختر نموذجًا',
6470
+ 'modelRoute.trigger': 'نموذج رسائل الالتزام بالذكاء الاصطناعي',
6471
+ 'modelRoute.modelRow': 'النموذج',
6472
+ 'modelRoute.effortRow': 'الاستدلال',
6473
+ 'modelRoute.effortDefault': 'اتبع الافتراضي',
6474
+ 'modelRoute.empty': 'لا توجد نماذج متاحة',
6361
6475
  'composer.behindHint': 'التزامات جديدة في المستودع البعيد (قابلة للسحب)',
6362
6476
  'composer.aheadHint': 'التزامات محلية بانتظار الدفع',
6363
6477
  'composer.inWorktree': 'مسحوب في شجرة عمل أخرى',
@@ -6694,6 +6808,12 @@ window.__ModuleLoader__.load({
6694
6808
  'composer.changesHint': 'बिना कमिट बदलाव',
6695
6809
  'composer.fileHint': '{a} जोड़ी गईं · {d} हटाई गईं',
6696
6810
  'composer.linesHint': '+{ins} −{del} पंक्तियाँ',
6811
+ 'modelRoute.pick': 'मॉडल चुनें',
6812
+ 'modelRoute.trigger': 'AI कमिट संदेश मॉडल',
6813
+ 'modelRoute.modelRow': 'मॉडल',
6814
+ 'modelRoute.effortRow': 'तर्क',
6815
+ 'modelRoute.effortDefault': 'डिफ़ॉल्ट का पालन करें',
6816
+ 'modelRoute.empty': 'कोई मॉडल उपलब्ध नहीं',
6697
6817
  'composer.behindHint': 'अपस्ट्रीम के नए कमिट (खींचने योग्य)',
6698
6818
  'composer.aheadHint': 'पुश के लिए प्रतीक्षारत स्थानीय कमिट',
6699
6819
  'composer.inWorktree': 'दूसरे वर्कट्री में चेकआउट',
@@ -7030,6 +7150,12 @@ window.__ModuleLoader__.load({
7030
7150
  'composer.changesHint': 'การเปลี่ยนแปลงที่ยังไม่คอมมิต',
7031
7151
  'composer.fileHint': 'เพิ่ม {a} · ลบ {d} ไฟล์',
7032
7152
  'composer.linesHint': '+{ins} −{del} บรรทัด',
7153
+ 'modelRoute.pick': 'เลือกโมเดล',
7154
+ 'modelRoute.trigger': 'โมเดลข้อความคอมมิต AI',
7155
+ 'modelRoute.modelRow': 'โมเดล',
7156
+ 'modelRoute.effortRow': 'การใช้เหตุผล',
7157
+ 'modelRoute.effortDefault': 'ตามค่าเริ่มต้น',
7158
+ 'modelRoute.empty': 'ไม่มีโมเดลที่ใช้ได้',
7033
7159
  'composer.behindHint': 'คอมมิตใหม่จากรีโมต (ดึงได้)',
7034
7160
  'composer.aheadHint': 'คอมมิตในเครื่องรอพุช',
7035
7161
  'composer.inWorktree': 'ถูกเช็คเอาต์ในเวิร์กทรีอื่น',
@@ -7299,6 +7425,19 @@ window.__ModuleLoader__.load({
7299
7425
  function isAuthRejection(status) {
7300
7426
  return status === 401 || status === 403
7301
7427
  }
7428
+
7429
+ /* Whether a proxy answer looks like OUR route (a JSON object). With no
7430
+ remote-web-ui installed the SPA static fallback serves /remote/<path> as
7431
+ '405 Method Not Allowed' with an EMPTY body (issue #20: on a plain-HTTP
7432
+ LAN page the panel surfaced a bare 'HTTP 405' instead of the real
7433
+ 403 refusal). A non-JSON retry therefore proves there is no proxy, and
7434
+ the original answer must be what the caller sees. Cloning leaves the
7435
+ caller's own body untouched. */
7436
+ async function answersJson(response) {
7437
+ let body = null
7438
+ try { body = await response.clone().json() } catch (error) { return false }
7439
+ return body !== null && typeof body === 'object'
7440
+ }
7302
7441
  /* ---- end remote channel fallback core ---- */
7303
7442
 
7304
7443
  /* Sticky remote-channel mode: once the proxy has answered, later calls go
@@ -7320,13 +7459,15 @@ window.__ModuleLoader__.load({
7320
7459
  } else {
7321
7460
  response = await fetch(direct, init)
7322
7461
  if (isAuthRejection(response.status) === true) {
7323
- /* Off the origin's trust path: try the pairing proxy once. With no
7324
- remote-web-ui installed the proxy path 404s — the retry then also
7325
- fails, and the ORIGINAL rejection is what the caller sees (a
7326
- proxy answer that is itself an auth rejection is discarded the
7327
- same way, so a genuine cross-site refusal can never be masked). */
7462
+ /* Off the origin's trust path: try the pairing proxy once. It has to
7463
+ answer like our route (a JSON body) to take over: with no
7464
+ remote-web-ui installed the SPA fallback serves the retry as 405
7465
+ with an empty body, and that non-answer must not outrank the
7466
+ ORIGINAL rejection (issue #20). A proxy answer that is itself an
7467
+ auth rejection is discarded the same way, so a genuine refusal can
7468
+ never be masked. */
7328
7469
  const retried = await fetch(remoteFallbackUrl(direct), init)
7329
- if (isAuthRejection(retried.status) !== true) {
7470
+ if (isAuthRejection(retried.status) !== true && (await answersJson(retried)) === true) {
7330
7471
  viaRemoteChannel = true
7331
7472
  response = retried
7332
7473
  }
@@ -11060,6 +11201,9 @@ window.__ModuleLoader__.load({
11060
11201
  function ChangesPanel(props) {
11061
11202
  const t = props.t
11062
11203
  const summary = props.summary
11204
+ /* The panel-side model picker (issue #19) rides the official model
11205
+ directories, soft-injected into a module ref at apply time — see
11206
+ ModelRoutePicker for why a bare ctx property read cannot work. */
11063
11207
  const [message, setMessage] = useState('')
11064
11208
  const [amend, setAmend] = useState(false)
11065
11209
  const [collapsed, setCollapsed] = useState(() => {
@@ -11358,6 +11502,7 @@ window.__ModuleLoader__.load({
11358
11502
  title: t('changes.aiQuota'),
11359
11503
  onClick: () => { if (message.trim() === '') void aiRun(); else setAiAsk(true) },
11360
11504
  }, aiBusy === true ? t('changes.aiBusy') : t('changes.aiWrite')),
11505
+ E(ModelRoutePicker, { sessionId: props.sessionId, t: t }),
11361
11506
  E('button', {
11362
11507
  type: 'button', className: 'dig-btn dig-btn-primary dig-btn-small',
11363
11508
  disabled: message.trim() === '' || props.busy === true,
@@ -11379,6 +11524,7 @@ window.__ModuleLoader__.load({
11379
11524
  disabled: aiBusy === true || props.busy === true,
11380
11525
  onClick: () => { if (message.trim() === '') void aiRun(); else setAiAsk(true) },
11381
11526
  }, aiBusy === true ? t('changes.aiBusy') : t('changes.aiWrite')),
11527
+ E(ModelRoutePicker, { sessionId: props.sessionId, t: t }),
11382
11528
  E('span', { className: 'dig-commit-ai-hint' }, t('changes.aiQuota')),
11383
11529
  aiUndo === null ? null : E('button', {
11384
11530
  type: 'button', className: 'dig-link',
@@ -15200,6 +15346,22 @@ window.__ModuleLoader__.load({
15200
15346
  '.dig-cs-item-note{color:var(--dsw-alias-label-tertiary);font-size:11px;flex:none}',
15201
15347
  '.dig-cs-item-counts{display:inline-flex;gap:5px;margin-left:auto;flex:none;font-variant-numeric:tabular-nums;font-size:11px}',
15202
15348
  '.dig-cs-static{color:var(--dsw-alias-label-tertiary);cursor:default}',
15349
+ /* ---- ModelRoutePicker (issue #19) ---- */
15350
+ '.dig-mrp-root{position:relative;display:inline-flex;flex:none;min-width:0}',
15351
+ '.dig-mrp-trigger{display:inline-flex;align-items:center;gap:4px;min-width:0;max-width:220px;font-size:11px;line-height:16px}',
15352
+ '.dig-mrp-trigger[data-open=true]{color:var(--dsw-alias-brand-primary)}',
15353
+ '.dig-mrp-label{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}',
15354
+ '.dig-mrp-effort{flex:none;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:10px}',
15355
+ '.dig-mrp-menu{max-width:300px}',
15356
+ '.dig-mrp-row .dig-cs-item-name{display:none}',
15357
+ '.dig-mrp-row-label{flex:none}',
15358
+ '.dig-mrp-row-value{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;margin-left:auto;color:var(--dsw-alias-label-tertiary)}',
15359
+ '.dig-mrp-row-arrow{flex:none;display:inline-flex;color:var(--dsw-alias-label-tertiary)}',
15360
+ '.dig-mrp-back{color:var(--dsw-alias-label-tertiary)}',
15361
+ '.dig-mrp-group{padding:6px 10px 2px;color:var(--dsw-alias-label-tertiary);font-size:10px;line-height:14px}',
15362
+ '.dig-mrp-empty{padding:10px;color:var(--dsw-alias-label-tertiary);font-size:12px}',
15363
+ '.dig-mrp-error{padding:6px 10px;margin-bottom:2px;border-radius:var(--dsw-radius-sm,8px);background:rgba(220,38,38,.12);color:var(--dsw-alias-state-error-primary);font-size:11px;line-height:16px;word-break:break-word}',
15364
+ 'body[data-ds-dark-theme] .dig-mrp-error{background:rgba(248,113,113,.16)}',
15203
15365
  ].concat(LANE_TRACE_RULES).join('\n')
15204
15366
 
15205
15367
  /* ============================== plugin ============================== */
@@ -15429,6 +15591,179 @@ window.__ModuleLoader__.load({
15429
15591
  E('span', { className: 'dig-cs-chunk', 'aria-hidden': 'true' }, text)))
15430
15592
  }
15431
15593
 
15594
+ /* ---- ModelRoutePicker (issue #19) ------------------------------------
15595
+ The panel-side model + reasoning picker, bound to the SAME session model
15596
+ route the composer's own selector writes: the host's ui-model-selection
15597
+ plugin exposes the modelDirectories resolver, and directoryFor(sessionId)
15598
+ hands out the session-scoped directory whose select() IS
15599
+ sessions.selectModel — so this picker, the composer pill and the /model
15600
+ command can never disagree. The VIEW is hand-drawn (ModelSelect is not
15601
+ exported from the official package); the DATA and the selection semantics
15602
+ are official end to end. The resolver arrives through a soft inject into
15603
+ a module-level ref (a bare `ctx.modelDirectories` read throws "without
15604
+ inject" on a real host — cordis gates EVERY property read behind an
15605
+ inject declaration); until it lands, and on any older host, the picker
15606
+ hides itself instead of throwing, and the AI-write button still works
15607
+ through the host's served-models fallback. */
15608
+ let modelDirectoriesRef = null
15609
+ const modelDirectorySubs = new Set()
15610
+ function ModelRoutePicker(props) {
15611
+ const t = props.t
15612
+ const sessionId = typeof props.sessionId === 'string' && props.sessionId !== '' ? props.sessionId : 'default'
15613
+ const [injectVersion, setInjectVersion] = useState(0)
15614
+ const [directory, setDirectory] = useState(null)
15615
+ const [state, setState] = useState(null)
15616
+ const [open, setOpen] = useState(false)
15617
+ const [pane, setPane] = useState('root')
15618
+ const rootRef = useRef(null)
15619
+ /* The soft inject may land AFTER first paint (apply runs once, this slot
15620
+ mounts per session view): re-render when the module ref refills. */
15621
+ useEffect(() => {
15622
+ const bump = () => setInjectVersion((v) => v + 1)
15623
+ modelDirectorySubs.add(bump)
15624
+ return () => modelDirectorySubs.delete(bump)
15625
+ }, [])
15626
+ const resolver = modelDirectoriesRef
15627
+ const ok = resolver !== undefined && resolver !== null && typeof resolver.directoryFor === 'function'
15628
+ /* Resolve the session-scoped directory once per session; subscribe AFTER
15629
+ taking the snapshot so a change that lands between the two is not lost. */
15630
+ useEffect(() => {
15631
+ if (ok !== true) return undefined
15632
+ let dir = null
15633
+ try { dir = resolver.directoryFor(sessionId) } catch (error) { void error }
15634
+ if (dir === null || dir === undefined) return undefined
15635
+ setState(dir.store.getSnapshot())
15636
+ setDirectory(dir)
15637
+ /* Snapshot and subscription live on dir.store (uSES-safe); load()/
15638
+ select() hang directly off the directory. */
15639
+ return dir.store.subscribe(() => setState(dir.store.getSnapshot()))
15640
+ }, [ok, resolver, sessionId, injectVersion])
15641
+ /* The catalog loads on mount; failures surface through the store and the
15642
+ menu shows them. A rejected load must not unmount or disable anything. */
15643
+ useEffect(() => {
15644
+ if (directory === null) return undefined
15645
+ directory.load().catch(() => {})
15646
+ return undefined
15647
+ }, [directory])
15648
+ /* Outside close. pointerdown + capture: the change list captures
15649
+ pointerdown for the rubber band, so a plain mousedown listener can
15650
+ miss the very click that opened another menu (v0.19.2 lesson). */
15651
+ useEffect(() => {
15652
+ if (open !== true) return undefined
15653
+ const close = (event) => {
15654
+ const root = rootRef.current
15655
+ if (root !== null && root.contains(event.target) === true) return
15656
+ setOpen(false)
15657
+ setPane('root')
15658
+ }
15659
+ document.addEventListener('pointerdown', close, true)
15660
+ return () => document.removeEventListener('pointerdown', close, true)
15661
+ }, [open])
15662
+ if (ok !== true || directory === null) return null
15663
+ const s = state === null ? directory.getSnapshot() : state
15664
+ const current = s === null || s.current === undefined ? null : s.current
15665
+ const busy = s !== null && s.pending !== null && s.pending !== undefined
15666
+ const groups = s === null || s.groups === undefined ? [] : s.groups
15667
+ /* The current route's reasoning table, looked up through the SAME
15668
+ directory groups the menu renders (official effectiveEffort rule). */
15669
+ let routeGroup = null
15670
+ let routeModel = null
15671
+ if (current !== null) {
15672
+ routeGroup = groups.filter((row) => row.id === current.provider)[0] || null
15673
+ routeModel = routeGroup === null ? null : routeGroup.models.filter((row) => row.id === current.model)[0] || null
15674
+ }
15675
+ const reasoning = routeModel === null || routeModel.reasoning === undefined ? undefined : routeModel.reasoning
15676
+ const effectiveEffort = current === null ? undefined : current.reasoningEffort !== undefined ? current.reasoningEffort : reasoning === undefined ? undefined : reasoning.defaultEffort
15677
+ const effortName = (effort) => {
15678
+ if (reasoning !== undefined) {
15679
+ const hit = reasoning.efforts.filter((row) => row.id === effort)[0]
15680
+ if (hit !== undefined) return hit.name
15681
+ }
15682
+ return effort === undefined ? t('modelRoute.effortDefault') : effort
15683
+ }
15684
+ const effortLabel = current === null ? '' : reasoning === undefined
15685
+ ? (current.reasoningEffort === undefined ? '' : current.reasoningEffort)
15686
+ : (effectiveEffort === undefined ? t('modelRoute.effortDefault') : effortName(effectiveEffort))
15687
+ const modelName = current === null ? '' : routeModel === null ? current.model : routeModel.name
15688
+ const effortChoices = reasoning === undefined ? [] : ([
15689
+ ...(reasoning.defaultEffort === undefined ? [{ key: 'provider-default', effort: undefined, label: t('modelRoute.effortDefault') }] : []),
15690
+ ...reasoning.efforts.map((row) => ({ key: row.id, effort: row.id, label: row.name })),
15691
+ ])
15692
+ const closeMenu = () => { setOpen(false); setPane('root') }
15693
+ const choose = (selection) => {
15694
+ void directory.select(selection).then((result) => {
15695
+ if (result !== undefined && result !== null && result.ok !== true) return
15696
+ closeMenu()
15697
+ }).catch(() => {})
15698
+ }
15699
+ const triggerTitle = t('modelRoute.trigger') + (current === null ? '' : ' · ' + current.provider + '/' + current.model + (effortLabel === '' ? '' : ' · ' + effortLabel))
15700
+ return E('div', { className: 'dig-mrp-root', ref: rootRef },
15701
+ E('button', {
15702
+ type: 'button',
15703
+ className: 'dig-btn dig-btn-small dig-mrp-trigger',
15704
+ 'data-open': open === true ? 'true' : undefined,
15705
+ title: triggerTitle,
15706
+ onClick: () => { setOpen(!open); setPane('root') },
15707
+ },
15708
+ E(Icon, { name: 'settings', size: 11 }),
15709
+ E('span', { className: 'dig-mrp-label' }, current === null ? t('modelRoute.pick') : modelName),
15710
+ effortLabel === '' ? null : E('span', { className: 'dig-mrp-effort' }, effortLabel),
15711
+ E(Icon, { name: 'chevron', size: 9 })),
15712
+ open !== true ? null : E('div', { className: 'dig-cs-menu dig-mrp-menu', role: 'menu' },
15713
+ s !== null && typeof s.error === 'string' && s.error !== ''
15714
+ ? E('div', { className: 'dig-mrp-error', role: 'status' }, s.error)
15715
+ : null,
15716
+ pane === 'root'
15717
+ ? [
15718
+ E('button', {
15719
+ type: 'button', key: 'model', className: 'dig-cs-item dig-mrp-row', disabled: busy === true,
15720
+ onClick: () => setPane('model'),
15721
+ },
15722
+ E('span', { className: 'dig-mrp-row-label' }, t('modelRoute.modelRow')),
15723
+ E('span', { className: 'dig-mrp-row-value' }, current === null ? t('modelRoute.pick') : modelName),
15724
+ E('span', { className: 'dig-mrp-row-arrow' }, E(Icon, { name: 'chevron', size: 9 }))),
15725
+ E('button', {
15726
+ type: 'button', key: 'effort', className: 'dig-cs-item dig-mrp-row', disabled: busy === true || current === null || effortChoices.length === 0,
15727
+ onClick: () => setPane('effort'),
15728
+ },
15729
+ E('span', { className: 'dig-mrp-row-label' }, t('modelRoute.effortRow')),
15730
+ E('span', { className: 'dig-mrp-row-value' }, effortChoices.length === 0 ? '' : (effortLabel === '' ? t('modelRoute.effortDefault') : effortLabel)),
15731
+ E('span', { className: 'dig-mrp-row-arrow' }, E(Icon, { name: 'chevron', size: 9 }))),
15732
+ ]
15733
+ : [
15734
+ E('button', {
15735
+ type: 'button', key: 'back', className: 'dig-cs-item dig-mrp-back', disabled: busy === true,
15736
+ onClick: () => setPane('root'),
15737
+ }, '‹ ' + (pane === 'model' ? t('modelRoute.modelRow') : t('modelRoute.effortRow'))),
15738
+ ],
15739
+ pane === 'model' && groups.length === 0
15740
+ ? E('div', { className: 'dig-mrp-empty' }, t('modelRoute.empty'))
15741
+ : null,
15742
+ pane === 'model' && groups.map((group) => E(Fragment, { key: group.id },
15743
+ E('div', { className: 'dig-mrp-group' }, group.name === undefined || group.name === null || group.name === '' ? group.id : group.name),
15744
+ group.models.map((model) => {
15745
+ const selected = current !== null && current.provider === group.id && current.model === model.id
15746
+ return E('button', {
15747
+ type: 'button', key: model.id, className: 'dig-cs-item', 'data-current': selected === true ? 'true' : undefined,
15748
+ disabled: busy === true,
15749
+ title: group.id + '/' + model.id,
15750
+ onClick: () => choose({ provider: group.id, model: model.id }),
15751
+ },
15752
+ E('span', { className: 'dig-cs-item-name' }, model.name === undefined || model.name === null ? model.id : model.name),
15753
+ E('span', { className: 'dig-cs-item-note' }, selected === true ? E(Icon, { name: 'check', size: 11 }) : null))
15754
+ }))),
15755
+ pane === 'effort' && current !== null && effortChoices.map((level) => {
15756
+ const selected = effectiveEffort === level.effort
15757
+ return E('button', {
15758
+ type: 'button', key: level.key, className: 'dig-cs-item', 'data-current': selected === true ? 'true' : undefined,
15759
+ disabled: busy === true,
15760
+ onClick: () => choose({ provider: current.provider, model: current.model, reasoningEffort: level.effort }),
15761
+ },
15762
+ E('span', { className: 'dig-cs-item-name' }, level.label),
15763
+ E('span', { className: 'dig-cs-item-note' }, selected === true ? E(Icon, { name: 'check', size: 11 }) : null))
15764
+ })))
15765
+ }
15766
+
15432
15767
  function ComposerGitStatus(props) {
15433
15768
  const ctx = props.ctx
15434
15769
  const t = props.t
@@ -16330,6 +16665,20 @@ window.__ModuleLoader__.load({
16330
16665
  }, 'dsh-ide-git: session-switch prewarm')
16331
16666
  })
16332
16667
 
16668
+ /* ---- panel model picker: the official directories resolver ---- */
16669
+ /* ui-model-selection exposes the resolver as a CONTEXT PROPERTY, and
16670
+ cordis refuses bare property reads ("cannot get property … without
16671
+ inject") — the only legal consumer path is a declared inject. Soft:
16672
+ an older host without the service never fires this callback, the ref
16673
+ stays null and the picker hides itself (the AI-write button keeps its
16674
+ served-models fallback). Subscribers re-render the mounted pickers. */
16675
+ ctx.inject(['modelDirectories'], (mdCtx) => {
16676
+ const md = mdCtx.get('modelDirectories')
16677
+ if (md === undefined || md === null || typeof md.directoryFor !== 'function') return
16678
+ modelDirectoriesRef = md
16679
+ for (const notify of modelDirectorySubs) { try { notify() } catch (error) { void error } }
16680
+ })
16681
+
16333
16682
  /* ---- settings: the row Config is the rail's source of truth ---- */
16334
16683
  /* dsh >= 0.1.7 serves a plugin's row Config to the browser through
16335
16684
  ctx.configForms; from then on the settings card and the rail read that one
package/src/index.js CHANGED
@@ -1788,6 +1788,28 @@ function failureTextOf(failure) {
1788
1788
  return String(failure.code === undefined ? failure : failure.code)
1789
1789
  }
1790
1790
 
1791
+ /** The no-route fallback: the first provider this host serves that lists at
1792
+ least one model, and that provider's first model. Issue #19 — a fresh
1793
+ session (no model picked yet) used to fail the panel button with 'no-route'
1794
+ before the user ever saw a message; a commit message is too small a task to
1795
+ lecture anyone about routing. Errors per provider stay silent on purpose:
1796
+ a provider that fails its catalog simply is not a fallback candidate. */
1797
+ async function fallbackRouteOf() {
1798
+ const llm = commitServices.llm
1799
+ for (const row of llm.listProviders()) {
1800
+ try {
1801
+ const models = await llm.listModels(row.id)
1802
+ const model = models[0]
1803
+ if (model !== undefined && model !== null && typeof model.id === 'string' && model.id !== '') {
1804
+ return { provider: row.id, model: model.id }
1805
+ }
1806
+ } catch (error) {
1807
+ void error
1808
+ }
1809
+ }
1810
+ return null
1811
+ }
1812
+
1791
1813
  /** Route the call exactly like the session does when it talks to the model. */
1792
1814
  async function commitRouteOf(payload) {
1793
1815
  const wanted = typeof payload.model === 'string' ? payload.model.trim() : ''
@@ -1801,7 +1823,13 @@ async function commitRouteOf(payload) {
1801
1823
  try { header = session.requestHeader() } catch (error) { header = null; void error }
1802
1824
  const config = header === null || header === undefined || header.config === undefined || header.config === null ? {} : header.config
1803
1825
  if (typeof config.provider !== 'string' || typeof config.model !== 'string') {
1804
- throw new PanelError('no-route', 'this session has no model route yet; pick a model in the session first', 502)
1826
+ /* Issue #19: 'no-route' is the failure every blank session hit. The
1827
+ served fallback below keeps the button useful on a brand-new session;
1828
+ the panel's model picker (this release) is the user's visible way to
1829
+ steer it, and it drives the SAME session route once a model is picked. */
1830
+ const fallback = await fallbackRouteOf()
1831
+ if (fallback !== null) return { provider: fallback.provider, model: fallback.model, source: 'fallback' }
1832
+ throw new PanelError('no-models', 'no model route and no served models to fall back to; pick a model in the session first', 502)
1805
1833
  }
1806
1834
  return { provider: config.provider, model: config.model, source: 'session' }
1807
1835
  }
@@ -2058,6 +2086,61 @@ function headerOf(req, name) {
2058
2086
  return value
2059
2087
  }
2060
2088
 
2089
+ /* Normalize an authority ("host[:port]" or an absolute URL) to
2090
+ "hostname:port", filling the scheme default port when it is implicit.
2091
+ Returns '' for anything unparseable (including the "null" opaque origin). */
2092
+ function authorityKey(value, protocol) {
2093
+ const text = String(value === undefined || value === null ? '' : value).trim()
2094
+ if (text === '') return ''
2095
+ let hostname = text
2096
+ let port = ''
2097
+ let scheme = protocol
2098
+ if (text.includes('://') === true) {
2099
+ let parsed = null
2100
+ try { parsed = new URL(text) } catch (error) { parsed = null }
2101
+ if (parsed === null) return ''
2102
+ hostname = parsed.hostname
2103
+ port = parsed.port
2104
+ scheme = parsed.protocol
2105
+ } else {
2106
+ const bracketed = /^\[([^\]]+)\](?::(\d+))?$/.exec(text)
2107
+ if (bracketed !== null) {
2108
+ hostname = bracketed[1]
2109
+ port = bracketed[2] === undefined ? '' : bracketed[2]
2110
+ } else {
2111
+ const plain = /^([^:]+)(?::(\d+))?$/.exec(text)
2112
+ if (plain === null) return ''
2113
+ hostname = plain[1]
2114
+ port = plain[2] === undefined ? '' : plain[2]
2115
+ }
2116
+ }
2117
+ if (hostname === '') return ''
2118
+ if (port === '') port = scheme === 'https:' ? '443' : '80'
2119
+ return hostname.toLowerCase() + ':' + port
2120
+ }
2121
+
2122
+ /* Same-origin fallback for browsers that send no Sec-Fetch-* headers. A page
2123
+ served over plain HTTP from a non-loopback address is not a secure context,
2124
+ and the fetch-metadata headers are omitted there (issue #20: the git panel
2125
+ answered 403 on a LAN address). Origin and Referer are forbidden headers a
2126
+ page cannot forge — a cross-site request always carries the caller's own
2127
+ origin — so comparing either against the Host header restores the
2128
+ same-origin fence without trusting anything the request body claims. */
2129
+ function sameOriginOf(req, host) {
2130
+ const hostKey = authorityKey(host, 'http:')
2131
+ if (hostKey === '') return false
2132
+ /* A Host header without a port says nothing about the scheme, so either
2133
+ scheme default may be the one the browser used in Origin. */
2134
+ const candidates = new Set([hostKey])
2135
+ if (/:\d+$/.test(String(host).trim()) !== true) candidates.add(authorityKey(host, 'https:'))
2136
+ const matches = (value) => {
2137
+ const key = authorityKey(value, 'http:')
2138
+ return key !== '' && candidates.has(key)
2139
+ }
2140
+ if (matches(headerOf(req, 'origin')) === true) return true
2141
+ return matches(headerOf(req, 'referer'))
2142
+ }
2143
+
2061
2144
  /** Loopback authority, or a same-origin browser request (desktop shell / remote UI). */
2062
2145
  function isTrusted(req) {
2063
2146
  const host = headerOf(req, 'host')
@@ -2066,7 +2149,8 @@ function isTrusted(req) {
2066
2149
  if (hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1' || hostname === '0.0.0.0') return true
2067
2150
  if (/^127\./.test(hostname)) return true
2068
2151
  const site = headerOf(req, 'sec-fetch-site')
2069
- return site === 'same-origin' || site === 'same-site'
2152
+ if (site === 'same-origin' || site === 'same-site') return true
2153
+ return sameOriginOf(req, host)
2070
2154
  }
2071
2155
 
2072
2156
  function sendJson(res, status, body) {
@@ -2108,7 +2192,13 @@ function readJsonBody(req, limit = 1 << 20) {
2108
2192
 
2109
2193
  async function handle(req, res) {
2110
2194
  if (!isTrusted(req)) {
2111
- sendJson(res, 403, { ok: false, error: { code: 'forbidden', message: 'forbidden' } })
2195
+ sendJson(res, 403, {
2196
+ ok: false,
2197
+ error: {
2198
+ code: 'forbidden',
2199
+ message: 'forbidden: a non-loopback request must be same-origin (open this host from its own page) or come through the remote pairing proxy',
2200
+ },
2201
+ })
2112
2202
  return
2113
2203
  }
2114
2204
  if (req.method !== 'POST') {