chocomint 1.0.3 → 1.1.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.
data/public/edit/edit.js CHANGED
@@ -165,12 +165,35 @@ const FILE_ICONS = {
165
165
  jpg: '🖼️',
166
166
  jpeg: '🖼️',
167
167
  gif: '🖼️',
168
+ webp: '🖼️',
169
+ bmp: '🖼️',
168
170
  svg: '🖼️',
169
171
  ico: '🖼️',
172
+ avif: '🖼️',
173
+ tif: '🖼️',
174
+ tiff: '🖼️',
170
175
  pdf: '📕',
176
+ mp4: '🎬',
177
+ m4v: '🎬',
178
+ webm: '🎬',
179
+ mkv: '🎬',
180
+ mov: '🎬',
181
+ avi: '🎬',
182
+ ogv: '🎬',
183
+ mp3: '🎵',
184
+ wav: '🎵',
185
+ ogg: '🎵',
186
+ m4a: '🎵',
187
+ aac: '🎵',
188
+ flac: '🎵',
189
+ opus: '🎵',
171
190
  zip: '📦',
172
191
  gz: '📦',
173
192
  tar: '📦',
193
+ tgz: '📦',
194
+ bz2: '📦',
195
+ tbz: '📦',
196
+ tbz2: '📦',
174
197
  lock: '🔒',
175
198
  gitignore: '🚫',
176
199
  dockerfile: '🐳',
@@ -612,18 +635,22 @@ function removeTab(path) {
612
635
  const wasActive = currentPath === path;
613
636
  if (wasActive && autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
614
637
  openTabs.splice(idx, 1);
615
- tab.model.dispose();
638
+ // メディアタブは Monaco モデルを持たない (model=null)。テキスト/バイナリのみ破棄する。
639
+ if (tab.model) tab.model.dispose();
616
640
  if (wasActive) {
617
641
  currentPath = null;
618
642
  const next = openTabs[idx] || openTabs[idx - 1];
619
643
  if (next) {
620
644
  activateEditorTab(next.path);
621
645
  } else {
646
+ hideMediaView();
647
+ hideArchiveView();
622
648
  editor.setModel(null);
623
649
  document.getElementById('app').classList.add('no-tabs');
624
650
  setPathLabel('(ファイル未選択)');
625
651
  setDirty(false);
626
652
  setSaveStatus('');
653
+ updateEolStatus();
627
654
  document.querySelectorAll('#file-list li.file.active').forEach(x => x.classList.remove('active'));
628
655
  }
629
656
  }
@@ -677,6 +704,199 @@ function openFileByPath(path) {
677
704
  return openFile(path, li);
678
705
  }
679
706
 
707
+ // ---- メディアプレビュー (画像 / PDF / 動画 / 音声) ----
708
+ // メディアタブがアクティブなときだけ #media-view を表示し、Monaco を隠す。
709
+ // 生ファイルは /edit/raw?path= から取得する (種別ごとの Content-Type / Range 対応)。
710
+
711
+ // 現在 #media-view に載せている <video>/<audio> を止めるために保持する。
712
+ let currentMediaEl = null;
713
+
714
+ function rawUrl(path) {
715
+ return '/edit/raw?path=' + encodeURIComponent(path);
716
+ }
717
+
718
+ function showMediaView(tab) {
719
+ const app = document.getElementById('app');
720
+ const view = document.getElementById('media-view');
721
+ stopCurrentMedia();
722
+ view.innerHTML = '';
723
+ app.classList.add('show-media');
724
+ // PDF は縁まで使いたいので専用クラスで padding を外す (CSS 側で制御)。
725
+ app.classList.toggle('media-pdf', tab.media === 'pdf');
726
+
727
+ const url = rawUrl(tab.path);
728
+ const name = tab.path.split('/').pop();
729
+ let el;
730
+ switch (tab.media) {
731
+ case 'image':
732
+ el = document.createElement('img');
733
+ el.className = 'media-image';
734
+ el.alt = name;
735
+ el.src = url;
736
+ el.onerror = () => showMediaError(view, '画像を読み込めませんでした');
737
+ view.appendChild(el);
738
+ break;
739
+ case 'pdf':
740
+ el = document.createElement('iframe');
741
+ el.className = 'media-pdf';
742
+ el.title = name;
743
+ el.src = url;
744
+ view.appendChild(el);
745
+ break;
746
+ case 'video':
747
+ el = document.createElement('video');
748
+ el.className = 'media-video';
749
+ el.src = url;
750
+ el.controls = true;
751
+ el.onerror = () => showMediaError(view,
752
+ 'この動画はブラウザで再生できない形式の可能性があります (' + name + ')');
753
+ view.appendChild(el);
754
+ currentMediaEl = el;
755
+ break;
756
+ case 'audio': {
757
+ const card = document.createElement('div');
758
+ card.className = 'media-audio-card';
759
+ const label = document.createElement('div');
760
+ label.className = 'media-audio-name';
761
+ label.textContent = '🎵 ' + name;
762
+ el = document.createElement('audio');
763
+ el.className = 'media-audio';
764
+ el.src = url;
765
+ el.controls = true;
766
+ el.onerror = () => showMediaError(view,
767
+ 'この音声はブラウザで再生できない形式の可能性があります (' + name + ')');
768
+ card.appendChild(label);
769
+ card.appendChild(el);
770
+ view.appendChild(card);
771
+ currentMediaEl = el;
772
+ break;
773
+ }
774
+ default:
775
+ showMediaError(view, 'このファイルはプレビューできません');
776
+ }
777
+ }
778
+
779
+ function showMediaError(view, message) {
780
+ const p = document.createElement('div');
781
+ p.className = 'media-error';
782
+ p.textContent = message;
783
+ view.appendChild(p);
784
+ }
785
+
786
+ // 再生中のメディアを止める (タブ切り替え/クローズ時に音が鳴り続けないように)。
787
+ function stopCurrentMedia() {
788
+ if (currentMediaEl) {
789
+ try { currentMediaEl.pause(); } catch (e) { /* 既に破棄済み等は無視 */ }
790
+ currentMediaEl = null;
791
+ }
792
+ }
793
+
794
+ function hideMediaView() {
795
+ const app = document.getElementById('app');
796
+ if (!app.classList.contains('show-media')) return;
797
+ stopCurrentMedia();
798
+ app.classList.remove('show-media', 'media-pdf');
799
+ document.getElementById('media-view').innerHTML = '';
800
+ }
801
+
802
+ // ---- アーカイブプレビュー (zip / tar / tar.gz / bz2 等) ----
803
+ // 第一階層 (トップレベル) のエントリとメタデータを /edit/archive?path= から取得して表示する。
804
+
805
+ // バイト数を人間可読 (KB/MB 等) に整形する。null/未定義は空文字。
806
+ function formatBytes(n) {
807
+ if (n == null || isNaN(n)) return '';
808
+ if (n < 1024) return n + ' B';
809
+ const units = ['KB', 'MB', 'GB', 'TB'];
810
+ let v = n / 1024, i = 0;
811
+ while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
812
+ return (v < 10 ? v.toFixed(1) : Math.round(v)) + ' ' + units[i];
813
+ }
814
+
815
+ // アーカイブ形式のバッジ表示名。
816
+ const ARCHIVE_KIND_LABELS = {
817
+ tar_or_zip: 'ARCHIVE', gz: 'GZIP', bz2: 'BZIP2'
818
+ };
819
+
820
+ async function showArchiveView(tab) {
821
+ const app = document.getElementById('app');
822
+ const view = document.getElementById('archive-view');
823
+ app.classList.add('show-archive');
824
+ const name = tab.path.split('/').pop();
825
+ view.innerHTML = '<div class="archive-empty">読み込み中…</div>';
826
+
827
+ let data;
828
+ try {
829
+ const r = await fetch('/edit/archive?path=' + encodeURIComponent(tab.path));
830
+ data = await r.json();
831
+ if (!r.ok) throw new Error(data.error || 'アーカイブを読み取れませんでした');
832
+ } catch (err) {
833
+ // 取得中にタブが切り替わっていたら描画しない。
834
+ if (currentPath !== tab.path) return;
835
+ view.innerHTML = '';
836
+ view.appendChild(el('div', 'archive-error', '⚠ ' + err.message));
837
+ return;
838
+ }
839
+ if (currentPath !== tab.path) return; // 取得中に別タブへ切り替わっていたら破棄。
840
+ renderArchive(view, name, data);
841
+ }
842
+
843
+ // 単純な要素生成ヘルパ (class と textContent 付き)。
844
+ function el(tag, cls, text) {
845
+ const e = document.createElement(tag);
846
+ if (cls) e.className = cls;
847
+ if (text != null) e.textContent = text;
848
+ return e;
849
+ }
850
+
851
+ function renderArchive(view, name, data) {
852
+ view.innerHTML = '';
853
+
854
+ // ヘッダー (ファイル名 + 形式バッジ)。
855
+ const head = el('div', 'archive-head');
856
+ head.appendChild(el('span', 'archive-name', '📦 ' + name));
857
+ head.appendChild(el('span', 'archive-kind', ARCHIVE_KIND_LABELS[data.archive] || 'ARCHIVE'));
858
+ view.appendChild(head);
859
+
860
+ // メタデータ行 (エントリ数 / 合計サイズ / 圧縮サイズ)。
861
+ const meta = el('div', 'archive-meta');
862
+ const entryCount = data.total_entries != null ? data.total_entries : (data.entries || []).length;
863
+ meta.appendChild(el('span', null, entryCount + ' エントリ'));
864
+ if (data.total_size != null) meta.appendChild(el('span', null, '展開後 ' + formatBytes(data.total_size)));
865
+ if (data.compressed_size != null) meta.appendChild(el('span', null, '圧縮 ' + formatBytes(data.compressed_size)));
866
+ if (data.capped) meta.appendChild(el('span', 'archive-capped', '(一部のみ表示)'));
867
+ view.appendChild(meta);
868
+
869
+ // エントリ一覧 (第一階層)。
870
+ const entries = data.entries || [];
871
+ if (entries.length === 0) {
872
+ view.appendChild(el('div', 'archive-empty', '(エントリがありません)'));
873
+ return;
874
+ }
875
+ const ul = el('ul', 'archive-list');
876
+ for (const ent of entries) {
877
+ const li = el('li');
878
+ li.appendChild(el('span', 'a-icon', ent.dir ? '📁' : fileIcon(ent.name)));
879
+ li.appendChild(el('span', 'a-name', ent.name + (ent.dir ? '/' : '')));
880
+ // ディレクトリは配下件数、ファイルはサイズを右側に添える。
881
+ let sub = '';
882
+ if (ent.dir) {
883
+ if (ent.child_count != null) sub = ent.child_count + ' 項目';
884
+ } else if (ent.size != null) {
885
+ sub = formatBytes(ent.size);
886
+ }
887
+ if (sub) li.appendChild(el('span', 'a-sub', sub));
888
+ ul.appendChild(li);
889
+ }
890
+ view.appendChild(ul);
891
+ }
892
+
893
+ function hideArchiveView() {
894
+ const app = document.getElementById('app');
895
+ if (!app.classList.contains('show-archive')) return;
896
+ app.classList.remove('show-archive');
897
+ document.getElementById('archive-view').innerHTML = '';
898
+ }
899
+
680
900
  async function openFile(path, li) {
681
901
  if (!editor) return;
682
902
  // 既に開いているファイルなら、そのタブへ切り替えるだけ (再フェッチしない)。
@@ -686,6 +906,24 @@ async function openFile(path, li) {
686
906
  const data = await r.json();
687
907
  if (!r.ok) { alert(data.error || 'open failed'); return; }
688
908
 
909
+ // 画像 / PDF / 動画 / 音声はメディアタブとして開く (Monaco モデルは持たない)。
910
+ if (data.media) {
911
+ const tab = { path, model: null, viewState: null, dirty: false,
912
+ binary: false, media: data.media, archive: false };
913
+ openTabs.push(tab);
914
+ activateEditorTab(path, li);
915
+ return;
916
+ }
917
+
918
+ // zip / tar 等のアーカイブは第一階層一覧タブとして開く (Monaco モデルは持たない)。
919
+ if (data.archive) {
920
+ const tab = { path, model: null, viewState: null, dirty: false,
921
+ binary: false, media: null, archive: true };
922
+ openTabs.push(tab);
923
+ activateEditorTab(path, li);
924
+ return;
925
+ }
926
+
689
927
  // 新しいモデルを作ってタブに紐づける (以後このモデルが内容・undo 履歴を保持する)。
690
928
  const binary = !!data.binary;
691
929
  const content = binary
@@ -693,11 +931,45 @@ async function openFile(path, li) {
693
931
  : (data.content || '');
694
932
  const lang = binary ? 'plaintext' : langOf(path);
695
933
  const model = monaco.editor.createModel(content, lang);
696
- const tab = { path, model, viewState: null, dirty: false, binary };
934
+ // 元ファイルの改行コードをモデルへ反映する (Monaco は既定で LF に正規化してしまうため、
935
+ // CRLF のファイルを開いて保存すると LF に化ける。開いた時点の実際の改行を尊重する)。
936
+ if (!binary) {
937
+ const crlf = /\r\n/.test(content);
938
+ model.setEOL(crlf ? monaco.editor.EndOfLineSequence.CRLF
939
+ : monaco.editor.EndOfLineSequence.LF);
940
+ }
941
+ const tab = { path, model, viewState: null, dirty: false, binary, media: null, archive: false };
697
942
  openTabs.push(tab);
698
943
  activateEditorTab(path, li);
699
944
  }
700
945
 
946
+ // ステータスバーの改行コード表示を更新する。テキストを編集できるタブ (通常タブ) の
947
+ // ときだけ CRLF/LF を表示し、それ以外 (未選択・メディア・アーカイブ・バイナリ) では隠す。
948
+ function updateEolStatus() {
949
+ const btn = document.getElementById('statusbar-eol');
950
+ const nameEl = document.getElementById('statusbar-eol-name');
951
+ if (!btn || !nameEl) return;
952
+ const tab = currentPath ? findTab(currentPath) : null;
953
+ const editable = tab && tab.model && !tab.media && !tab.archive && !tab.binary;
954
+ if (!editable) { btn.hidden = true; return; }
955
+ btn.hidden = false;
956
+ nameEl.textContent = tab.model.getEOL() === '\r\n' ? 'CRLF' : 'LF';
957
+ }
958
+
959
+ // ステータスバーの改行コードをクリックしたときに CRLF ⇄ LF を切り替える。
960
+ // 切り替えは内容の編集にあたるので dirty 扱いにし、自動保存の有無はエディタ設定に従う
961
+ // (setDirty → scheduleAutoSave。自動保存が OFF なら手動 Ctrl+S まで保存されない)。
962
+ function toggleEol() {
963
+ const tab = currentPath ? findTab(currentPath) : null;
964
+ if (!tab || !tab.model || tab.media || tab.archive || tab.binary) return;
965
+ const toCrlf = tab.model.getEOL() !== '\r\n';
966
+ tab.model.setEOL(toCrlf ? monaco.editor.EndOfLineSequence.CRLF
967
+ : monaco.editor.EndOfLineSequence.LF);
968
+ updateEolStatus();
969
+ setDirty(true);
970
+ scheduleAutoSave();
971
+ }
972
+
701
973
  // 指定パスのエディタタブをアクティブにする。Monaco のモデルを差し替え、ビュー状態を復元する。
702
974
  // (ターミナルタブ用の activateTab とは別関数なので名前を分けている)
703
975
  function activateEditorTab(path, li) {
@@ -717,15 +989,34 @@ function activateEditorTab(path, li) {
717
989
  setPathLabel(path);
718
990
 
719
991
  document.getElementById('app').classList.remove('no-tabs');
720
- suppressChangeEvent = true;
721
- editor.setModel(tab.model);
722
- suppressChangeEvent = false;
723
- if (tab.viewState) editor.restoreViewState(tab.viewState);
724
- editor.updateOptions({ readOnly: tab.binary });
725
- editor.focus();
992
+ if (tab.media) {
993
+ // メディアタブ: Monaco はモデルを外して隠し、メディアビューアを表示する。
994
+ hideArchiveView();
995
+ suppressChangeEvent = true;
996
+ editor.setModel(null);
997
+ suppressChangeEvent = false;
998
+ showMediaView(tab);
999
+ } else if (tab.archive) {
1000
+ // アーカイブタブ: Monaco を隠し、第一階層一覧を表示する。
1001
+ hideMediaView();
1002
+ suppressChangeEvent = true;
1003
+ editor.setModel(null);
1004
+ suppressChangeEvent = false;
1005
+ showArchiveView(tab);
1006
+ } else {
1007
+ hideMediaView();
1008
+ hideArchiveView();
1009
+ suppressChangeEvent = true;
1010
+ editor.setModel(tab.model);
1011
+ suppressChangeEvent = false;
1012
+ if (tab.viewState) editor.restoreViewState(tab.viewState);
1013
+ editor.updateOptions({ readOnly: tab.binary });
1014
+ editor.focus();
1015
+ }
726
1016
 
727
1017
  isDirty = tab.dirty;
728
1018
  setSaveStatus('');
1019
+ updateEolStatus();
729
1020
  renderEditorTabs();
730
1021
 
731
1022
  // ファイル一覧側のハイライトを同期する (li が渡されなければパスから探す)。
@@ -793,6 +1084,9 @@ async function saveCurrent(opts) {
793
1084
  // ファイル未選択で手動保存 (Ctrl+S / メニュー) されたときは、保存先を尋ねる
794
1085
  // ダイアログを開く (自動保存はそもそも未選択時は動かないので auto では出さない)。
795
1086
  if (!currentPath) { if (!auto) openSaveAsDialog(); return; }
1087
+ // メディア/アーカイブタブは編集対象ではないので保存しない。
1088
+ const cur = findTab(currentPath);
1089
+ if (cur && (cur.media || cur.archive)) return;
796
1090
  // バイナリ表示中 (readOnly) は保存しない (プレースホルダで上書きしないため)。
797
1091
  if (editor.getRawOptions && editor.getRawOptions().readOnly) return;
798
1092
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
@@ -1166,7 +1460,23 @@ function addPendingMsg() {
1166
1460
  sp.textContent = SPINNER_FRAMES[0];
1167
1461
  head.appendChild(sp);
1168
1462
  head.appendChild(document.createTextNode(' AI 考え中...'));
1463
+ // 経過時間をリアルタイム表示 (例: 30s)。1 秒ごとに更新する。
1464
+ const elapsed = document.createElement('span');
1465
+ elapsed.className = 'pending-elapsed';
1466
+ head.appendChild(elapsed);
1169
1467
  b.appendChild(head);
1468
+ const startedAt = Date.now();
1469
+ const updateElapsed = () => {
1470
+ elapsed.textContent = '(' + Math.floor((Date.now() - startedAt) / 1000) + 's)';
1471
+ };
1472
+ updateElapsed();
1473
+ const elapsedTimer = setInterval(updateElapsed, 1000);
1474
+ div._startedAt = startedAt;
1475
+ // 経過秒 (実行時間) を確定して返す。タイマーも止める (二重停止は無害)。
1476
+ div._finishElapsed = () => {
1477
+ clearInterval(elapsedTimer);
1478
+ return Math.round((Date.now() - startedAt) / 1000);
1479
+ };
1170
1480
  // ツール実行の進捗行を並べるコンテナ (SSE の tool_start / tool_done で更新)。
1171
1481
  const tools = document.createElement('div');
1172
1482
  tools.className = 'pending-tools';
@@ -1178,6 +1488,7 @@ function addPendingMsg() {
1178
1488
  // remove 時に回転対象からも外す (ツール行のスピナーも含めて)。
1179
1489
  const origRemove = div.remove.bind(div);
1180
1490
  div.remove = () => {
1491
+ clearInterval(elapsedTimer);
1181
1492
  spinnerTargets.delete(sp);
1182
1493
  tools.querySelectorAll('.pending-spinner').forEach(s => spinnerTargets.delete(s));
1183
1494
  origRemove();
@@ -1245,6 +1556,24 @@ function pendingToolDone(pending, ev) {
1245
1556
  chatLog.scrollTop = chatLog.scrollHeight;
1246
1557
  }
1247
1558
 
1559
+ // trace_id に対応する監査ログ (実行の詳細) の URL。エディタと同一オリジンで開ける。
1560
+ function traceLogHref(traceId) {
1561
+ return '/logs?trace_id=' + encodeURIComponent(traceId);
1562
+ }
1563
+
1564
+ // 完了マーク (✓/✕) とツール名を、その実行の監査ログへのリンクにする。
1565
+ // trace_id が判明した時点 (result 受信後) に呼ぶ。既にリンク済みなら href だけ更新する。
1566
+ function linkToolRow(row, traceId) {
1567
+ if (!traceId) return;
1568
+ const href = traceLogHref(traceId);
1569
+ const title = 'この実行の監査ログを開く';
1570
+ row.querySelectorAll('.ptool-mark, .ptool-label, .step-mark, .step-label').forEach(el => {
1571
+ el.classList.add('log-link');
1572
+ el.onclick = () => window.open(href, '_blank', 'noopener');
1573
+ el.title = title;
1574
+ });
1575
+ }
1576
+
1248
1577
  // 軽量 Markdown → HTML 変換 (外部ライブラリ非依存 / オフライン用)。
1249
1578
  // XSS 防止のため、まず全体を HTML エスケープしてから Markdown 記法だけを HTML 化する。
1250
1579
  function escapeHtml(s) {
@@ -1335,12 +1664,19 @@ function addUserMsg(text) {
1335
1664
  }
1336
1665
 
1337
1666
  // AI 発言: 枠なしの地の文 (ラベルなし)。回答は Markdown として描画する。
1338
- function addAiMsg(text) {
1667
+ // elapsedSec: 実行時間 ()。渡されれば発言の下に控えめに「実行時間 Ns」を添える。
1668
+ function addAiMsg(text, elapsedSec) {
1339
1669
  const div = document.createElement('div');
1340
1670
  div.className = 'msg ai';
1341
1671
  const b = document.createElement('div');
1342
1672
  b.className = 'bubble markdown';
1343
1673
  b.innerHTML = renderMarkdown(text);
1674
+ if (elapsedSec != null) {
1675
+ const meta = document.createElement('div');
1676
+ meta.className = 'msg-elapsed';
1677
+ meta.textContent = '実行時間 ' + elapsedSec + 's';
1678
+ b.appendChild(meta);
1679
+ }
1344
1680
  div.appendChild(b);
1345
1681
  chatLog.appendChild(div);
1346
1682
  chatLog.scrollTop = chatLog.scrollHeight;
@@ -1349,7 +1685,7 @@ function addAiMsg(text) {
1349
1685
 
1350
1686
  // 実行ステップ 1 件を「番号・ツールラベル・対象・成否・出力」の構造で描画する。
1351
1687
  // 生 JSON をそのまま出さず、人間が追える形にするのが目的。
1352
- function renderStep(s, no) {
1688
+ function renderStep(s, no, traceId) {
1353
1689
  const st = document.createElement('div');
1354
1690
  st.className = 'step' + (s.status === 'PASS' ? '' : ' failed');
1355
1691
 
@@ -1383,6 +1719,9 @@ function renderStep(s, no) {
1383
1719
 
1384
1720
  st.appendChild(head);
1385
1721
 
1722
+ // trace_id があれば、完了マークとツール名をこの実行の監査ログへのリンクにする。
1723
+ if (traceId) linkToolRow(st, traceId);
1724
+
1386
1725
  // 出力は改行を保った等幅ブロックで表示。空なら控えめに「(出力なし)」。
1387
1726
  const out = document.createElement('code');
1388
1727
  out.className = 'out';
@@ -1397,9 +1736,10 @@ function renderStep(s, no) {
1397
1736
  }
1398
1737
 
1399
1738
  // AI の実行結果 (ステータスバッジ + 返信 + 各ツールの出力) を描画する。
1400
- function renderAiResult(data) {
1739
+ // elapsedSec: 実行時間 ()。渡されればバッジに「実行時間 Ns」を添える。
1740
+ function renderAiResult(data, elapsedSec) {
1401
1741
  // 会話応答 (ツール実行なし) は自然文だけを地の文として表示する。
1402
- if (data.reply) return addAiMsg(data.reply);
1742
+ if (data.reply) return addAiMsg(data.reply, elapsedSec);
1403
1743
 
1404
1744
  const status = data.status || 'FAIL';
1405
1745
  const div = document.createElement('div');
@@ -1411,7 +1751,9 @@ function renderAiResult(data) {
1411
1751
  badge.className = 'badge';
1412
1752
  const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
1413
1753
  // attempts は「LLM への試行回数」。1 回なら冗長なので添えない。
1414
- badge.textContent = statusText + (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '');
1754
+ badge.textContent = statusText +
1755
+ (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '') +
1756
+ (elapsedSec != null ? ' · 実行時間 ' + elapsedSec + 's' : '');
1415
1757
  b.appendChild(badge);
1416
1758
 
1417
1759
  // AI による実行結果の要約 (Markdown)。生のツール出力より上に目立たせて表示。
@@ -1440,7 +1782,7 @@ function renderAiResult(data) {
1440
1782
  if (steps.length) {
1441
1783
  const wrap = document.createElement('div');
1442
1784
  wrap.className = 'steps';
1443
- steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1)));
1785
+ steps.forEach((s, i) => wrap.appendChild(renderStep(s, i + 1, data.trace_id)));
1444
1786
  // 要約があるときは実行の詳細を折りたたむ (まとめを主役にする)。
1445
1787
  if (data.summary) {
1446
1788
  const det = document.createElement('details');
@@ -1476,7 +1818,8 @@ function clearChat() {
1476
1818
  }
1477
1819
 
1478
1820
  // 現在のチャット表示テキストを AI に要約させ、ログを短い要約 1 件に畳み込む。
1479
- // サーバー側は会話履歴を保持しないため、これは表示の圧縮のみ (AI の文脈には影響しない)。
1821
+ // 履歴は表示ログから毎回組み立てて送るため、これでログを畳めば次回送信の
1822
+ // history も要約後の短い文脈に置き換わる (肥大化を防ぐ)。
1480
1823
  async function compactChat() {
1481
1824
  const transcript = collectTranscript();
1482
1825
  if (!transcript.trim()) { clearChat(); return; }
@@ -1518,6 +1861,20 @@ function collectTranscript() {
1518
1861
  return lines.join('\n');
1519
1862
  }
1520
1863
 
1864
+ // これまでのチャットログを /edit/chat に渡す履歴配列に書き出す。
1865
+ // サーバーは会話履歴を保持しないため、送信のたびに表示済みログから組み立てて送る。
1866
+ function collectChatHistory() {
1867
+ const history = [];
1868
+ chatLog.querySelectorAll('.msg').forEach(m => {
1869
+ // pending (AI 考え中) 行はまだ確定した発言ではないので含めない。
1870
+ if (m.querySelector('.pending-head')) return;
1871
+ const role = m.classList.contains('user') ? 'user' : 'assistant';
1872
+ const text = (m.textContent || '').replace(/\s+/g, ' ').trim();
1873
+ if (text) history.push({ role, content: text });
1874
+ });
1875
+ return history;
1876
+ }
1877
+
1521
1878
  // /compact の結果 (これまでの会話のまとめ) を専用の見出し付きで表示する。
1522
1879
  function addCompactSummary(text) {
1523
1880
  const div = document.createElement('div');
@@ -1539,7 +1896,9 @@ function addCompactSummary(text) {
1539
1896
 
1540
1897
  // 最終結果に伴うエディタ反映とファイル一覧更新 (描画とは分離した副作用処理)。
1541
1898
  function applyResultSideEffects(data) {
1542
- if (data.content != null && editor && currentPath) {
1899
+ const curTab = currentPath ? findTab(currentPath) : null;
1900
+ // メディア/アーカイブタブ表示中は Monaco にモデルが無いので content を流し込まない。
1901
+ if (data.content != null && editor && currentPath && !(curTab && (curTab.media || curTab.archive))) {
1543
1902
  if (autoSaveTimer) { clearTimeout(autoSaveTimer); autoSaveTimer = null; }
1544
1903
  setEditorValue(data.content); // AI が編集した最新内容を反映 (既にディスクへ保存済みなので dirty にはしない)
1545
1904
  setDirty(false);
@@ -1559,11 +1918,19 @@ function finalizePending(pending, data) {
1559
1918
  const badge = document.createElement('span');
1560
1919
  badge.className = 'badge';
1561
1920
  const statusText = status === 'PASS' ? '✓ 完了' : '✕ 失敗';
1562
- badge.textContent = statusText + (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '');
1921
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1922
+ badge.textContent = statusText +
1923
+ (data.attempts > 1 ? ' · 試行 ' + data.attempts + ' 回' : '') +
1924
+ (elapsedSec != null ? ' · 実行時間 ' + elapsedSec + 's' : '');
1563
1925
  if (head) head.replaceWith(badge); else b.insertBefore(badge, b.firstChild);
1564
1926
 
1565
- // 要約 (Markdown) はツール一覧の上に目立たせて置く。
1927
+ // trace_id が判明したので、各ツール行 (完了マーク + ツール名) を監査ログへのリンクにする。
1566
1928
  const tools = pending._toolsEl;
1929
+ if (data.trace_id && tools) {
1930
+ tools.querySelectorAll('.ptool').forEach(row => linkToolRow(row, data.trace_id));
1931
+ }
1932
+
1933
+ // 要約 (Markdown) はツール一覧の上に目立たせて置く。
1567
1934
  if (data.summary) {
1568
1935
  const sum = document.createElement('div');
1569
1936
  sum.className = 'summary markdown';
@@ -1599,8 +1966,10 @@ function handleChatEvent(pending, evName, dataStr) {
1599
1966
  // 会話応答 (reply) はツール実行が無いので、pending を消して地の文で表示。
1600
1967
  // ツール実行があった場合は、進捗表示を消さずにその場で仕上げる。
1601
1968
  if (data.reply || !pending._hadTool) {
1969
+ // pending 破棄前に実行時間を確定して結果表示へ渡す。
1970
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1602
1971
  pending.remove();
1603
- renderAiResult(data);
1972
+ renderAiResult(data, elapsedSec);
1604
1973
  } else {
1605
1974
  finalizePending(pending, data);
1606
1975
  }
@@ -1610,8 +1979,9 @@ function handleChatEvent(pending, evName, dataStr) {
1610
1979
  if (pending._hadTool) {
1611
1980
  finalizePending(pending, { status: 'FAIL', error: data.error || 'エラー' });
1612
1981
  } else {
1982
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1613
1983
  pending.remove();
1614
- renderAiResult({ status: 'FAIL', error: data.error || 'エラー' });
1984
+ renderAiResult({ status: 'FAIL', error: data.error || 'エラー' }, elapsedSec);
1615
1985
  }
1616
1986
  }
1617
1987
  }
@@ -1621,6 +1991,8 @@ async function sendChat() {
1621
1991
  if (!instruction) return;
1622
1992
  // /clear /compact はローカルコマンド (AI へは送らない)。
1623
1993
  if (handleSlashCommand(instruction)) return;
1994
+ // 今回の発言を DOM に足す前に、これまでのログを履歴として集めておく。
1995
+ const history = collectChatHistory();
1624
1996
  chatInput.value = '';
1625
1997
  addUserMsg(instruction);
1626
1998
  chatForm.classList.add('busy');
@@ -1630,13 +2002,14 @@ async function sendChat() {
1630
2002
  try {
1631
2003
  const r = await fetch('/edit/chat', {
1632
2004
  method: 'POST', headers: { 'content-type': 'application/json' },
1633
- body: JSON.stringify({ path: currentPath || '', instruction, stream: true })
2005
+ body: JSON.stringify({ path: currentPath || '', instruction, history, stream: true })
1634
2006
  });
1635
2007
  if (!r.ok || !r.body) {
1636
2008
  // SSE を開始できなかった場合は本文を JSON として読んでフォールバック。
1637
2009
  const data = await r.json().catch(() => ({ status: 'FAIL', error: r.status + ' error' }));
2010
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1638
2011
  pending.remove();
1639
- renderAiResult(data);
2012
+ renderAiResult(data, elapsedSec);
1640
2013
  applyResultSideEffects(data);
1641
2014
  return;
1642
2015
  }
@@ -1661,8 +2034,9 @@ async function sendChat() {
1661
2034
  }
1662
2035
  }
1663
2036
  } catch (err) {
2037
+ const elapsedSec = pending._finishElapsed ? pending._finishElapsed() : null;
1664
2038
  pending.remove();
1665
- renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message });
2039
+ renderAiResult({ status: 'FAIL', error: 'エラー: ' + err.message }, elapsedSec);
1666
2040
  } finally {
1667
2041
  stopSpinner();
1668
2042
  chatForm.classList.remove('busy');
@@ -2172,6 +2546,7 @@ async function gitCheckout(branch, create) {
2172
2546
  }
2173
2547
 
2174
2548
  document.getElementById('statusbar-branch').onclick = () => openBranchDialog();
2549
+ document.getElementById('statusbar-eol').onclick = () => toggleEol();
2175
2550
  document.getElementById('branch-cancel').onclick = closeBranchDialog;
2176
2551
  document.getElementById('branch-close').onclick = closeBranchDialog;
2177
2552
  document.getElementById('branch-create').onclick = () => {
@@ -2221,6 +2596,119 @@ remoteUrlInput.addEventListener('keydown', (e) => {
2221
2596
  });
2222
2597
  initDraggableWindow(remoteDialog);
2223
2598
 
2599
+ // ---- モデル選択 (ステータスバー) ----
2600
+ const modelDialog = document.getElementById('model-dialog');
2601
+ const modelNameEl = document.getElementById('statusbar-model-name');
2602
+ const modelOllamaSelect = document.getElementById('model-ollama-select');
2603
+ const modelOllamaEmpty = document.getElementById('model-ollama-empty');
2604
+ const modelOpenrouterInput = document.getElementById('model-openrouter-input');
2605
+ const modelOpenrouterReady = document.getElementById('model-openrouter-ready');
2606
+ const modelOpenrouterNokey = document.getElementById('model-openrouter-nokey');
2607
+ const modelError = document.getElementById('model-error');
2608
+ // ダイアログ内で選択中の provider ("ollama" | "openrouter")。
2609
+ let modelSelectedProvider = 'ollama';
2610
+ // /edit/models の直近の応答 (現在値と選択肢) を保持する。
2611
+ let modelInfo = null;
2612
+
2613
+ // ステータスバーのモデル名を更新する (起動時と切り替え成功時に呼ぶ)。
2614
+ function updateModelStatus(provider, model) {
2615
+ if (!modelNameEl) return;
2616
+ modelNameEl.textContent = model || 'モデル未設定';
2617
+ const btn = document.getElementById('statusbar-model');
2618
+ if (btn) btn.title = `AI モデルを選択 (現在: ${provider} / ${model || '未設定'})`;
2619
+ }
2620
+
2621
+ // 起動時に現在のモデルを取得してステータスバーに表示する。
2622
+ async function initModelStatus() {
2623
+ try {
2624
+ modelInfo = await gitFetch('/edit/models');
2625
+ updateModelStatus(modelInfo.provider, modelInfo.model);
2626
+ } catch {
2627
+ // 取得に失敗しても致命的ではない。"—" 表示のままにする。
2628
+ }
2629
+ }
2630
+
2631
+ // provider タブを切り替えて対応ペインを表示する。
2632
+ function selectModelProvider(provider) {
2633
+ modelSelectedProvider = provider;
2634
+ modelError.textContent = '';
2635
+ document.getElementById('model-tab-ollama').classList.toggle('active', provider === 'ollama');
2636
+ document.getElementById('model-tab-openrouter').classList.toggle('active', provider === 'openrouter');
2637
+ document.getElementById('model-pane-ollama').hidden = provider !== 'ollama';
2638
+ document.getElementById('model-pane-openrouter').hidden = provider !== 'openrouter';
2639
+ }
2640
+
2641
+ async function openModelDialog() {
2642
+ modelError.textContent = '';
2643
+ // 開くたびに最新の情報 (インストール済みモデル・OpenRouter 可否) を取り直す。
2644
+ try {
2645
+ modelInfo = await gitFetch('/edit/models');
2646
+ } catch (err) {
2647
+ modelInfo = modelInfo || { provider: 'ollama', model: '', ollama_models: [], openrouter_available: false };
2648
+ }
2649
+ updateModelStatus(modelInfo.provider, modelInfo.model);
2650
+
2651
+ // Ollama: インストール済みモデルを select に流し込み、現在値を選択する。
2652
+ modelOllamaSelect.innerHTML = '';
2653
+ const models = modelInfo.ollama_models || [];
2654
+ models.forEach(name => {
2655
+ const opt = document.createElement('option');
2656
+ opt.value = name; opt.textContent = name;
2657
+ modelOllamaSelect.appendChild(opt);
2658
+ });
2659
+ modelOllamaEmpty.hidden = models.length > 0;
2660
+ modelOllamaSelect.hidden = models.length === 0;
2661
+ if (modelInfo.provider === 'ollama' && modelInfo.model && models.includes(modelInfo.model)) {
2662
+ modelOllamaSelect.value = modelInfo.model;
2663
+ }
2664
+
2665
+ // OpenRouter: キーがあれば入力欄、無ければ案内を表示する。
2666
+ const orAvailable = !!modelInfo.openrouter_available;
2667
+ modelOpenrouterReady.hidden = !orAvailable;
2668
+ modelOpenrouterNokey.hidden = orAvailable;
2669
+ modelOpenrouterInput.value = modelInfo.provider === 'openrouter' ? (modelInfo.model || '') : '';
2670
+
2671
+ selectModelProvider(modelInfo.provider || 'ollama');
2672
+ centerWindow(modelDialog);
2673
+ }
2674
+
2675
+ function closeModelDialog() { modelDialog.hidden = true; }
2676
+
2677
+ async function applyModel() {
2678
+ modelError.textContent = '';
2679
+ let model = '';
2680
+ if (modelSelectedProvider === 'ollama') {
2681
+ model = modelOllamaSelect.value;
2682
+ if (!model) { modelError.textContent = 'モデルを選択してください'; return; }
2683
+ } else {
2684
+ model = modelOpenrouterInput.value.trim();
2685
+ if (!model) { modelError.textContent = 'モデル ID を入力してください'; return; }
2686
+ }
2687
+ const okBtn = document.getElementById('model-ok');
2688
+ okBtn.disabled = true;
2689
+ try {
2690
+ const data = await gitPost('/edit/model', { provider: modelSelectedProvider, model });
2691
+ updateModelStatus(data.provider, data.model);
2692
+ closeModelDialog();
2693
+ } catch (err) {
2694
+ modelError.textContent = err.message;
2695
+ } finally {
2696
+ okBtn.disabled = false;
2697
+ }
2698
+ }
2699
+
2700
+ document.getElementById('statusbar-model').onclick = () => openModelDialog();
2701
+ document.getElementById('model-tab-ollama').onclick = () => selectModelProvider('ollama');
2702
+ document.getElementById('model-tab-openrouter').onclick = () => selectModelProvider('openrouter');
2703
+ document.getElementById('model-ok').onclick = () => applyModel();
2704
+ document.getElementById('model-cancel').onclick = closeModelDialog;
2705
+ document.getElementById('model-close').onclick = closeModelDialog;
2706
+ modelOpenrouterInput.addEventListener('keydown', (e) => {
2707
+ if (e.key === 'Enter') { e.preventDefault(); applyModel(); }
2708
+ else if (e.key === 'Escape') { e.preventDefault(); closeModelDialog(); }
2709
+ });
2710
+ initDraggableWindow(modelDialog);
2711
+
2224
2712
  // ---- 起動 ----
2225
2713
  refreshTree();
2226
2714
  initConsole();
@@ -2230,6 +2718,7 @@ initPanelView();
2230
2718
  initFilesTab();
2231
2719
  setAutoSave(autoSaveEnabled); // メニューのチェック表示を保存済み設定に合わせる
2232
2720
  refreshGitPanel();
2721
+ initModelStatus();
2233
2722
 
2234
2723
  // 自動保存 (debounce) が確定する前にタブを閉じようとした場合に警告する。
2235
2724
  window.addEventListener('beforeunload', (e) => {