Skip to content

m7 interactive tui_ja

Kazushi Kamegawa edited this page Sep 20, 2026 · 2 revisions

M7: 対話式TUI

状態: 計画済み、レビュー済み(実装前レビュー後に改訂)。issue #9と sub-issue #58、#59、#60で追跡。

目標

M4〜M6が確立した安全契約を弱めることなく、既存の修復パイプラインに対話式の チェックリストを追加する。M7はプレゼンテーション層の変更である: パッケージ 列挙、エイリアス解決、衝突検出、リンク検査、SymlinkService が行う最終 再検査は引き続き権威を持つ。

M7は次の順序でスタックされた、レビュー可能な3つのプルリクエストとして 提供する:

  1. #58 — 仮想端末機能と端末セッションのライフタイム
  2. #59 — 修復候補チェックリストとコマンドライン統合
  3. #60 — 進捗と最終結果サマリー

実装プルリクエストはマージ前にレビューされる。実装セッションの一部として マージしてはならない; マージとissueクローズはメンテナの操作として残る。

改訂に関する注記

マージ済みのM6コード(src/cli/Console.*、src/cli/Dispatch.cpp)に対する 実装前レビューにより、原案どおりに実装した場合にチェックリスト、リサイズ 処理、終了コード契約を誤動作させたであろう欠陥が見つかった。この改訂は 実装開始前にそれらの欠陥を修正する。最も影響の大きい修正: この計画の 元々の「既存の終了コード優先順位は保持される」という記述は誤りだった - マージ済みの runFix() はすでに interrupted を anyInsufficientPermission より優先しており、これは文書化された「権限不足は2を返す」という優先順位と 一致しない。M7はCLI自身の優先順位を修正する。下記の「進捗、サマリー、 終了コード」を参照。

コマンドラインとフォールバック契約

  • TUIは fix --tui によってのみ有効化される。
  • --tui を scan または test-rule と組み合わせるのは設定エラーであり、 終了コード3を返す。
  • --tui は --json と --yes と衝突する; どちらの組み合わせも終了コード3 を返す。これらのチェックは、既存の --json-with-fix-without---yes の 衝突チェックの隣、parseArguments()(applyPositionals() の後)に置く。 その手前にある --help/--version のショートサーキットには触れない。
  • --dry-run は許可される。選択されたエントリは再検査され報告されるが、 ファイルシステムの変更は起きない。
  • --no-color と NO_COLOR はSGR色のみを無効化する。TUIが必要とする カーソルと画面制御シーケンスは無効化しない。これは Console の既存の 挙動そのものである(tryEnableVirtualTerminal() は noColor に関わらず 実行される; --no-color は colorEnabled() のみをゲートする) - #58はこの既存の機能を公開するだけであり、変更するわけではない。
  • TUIは、対話的なstdinとstdoutに加えて、stdout上での ENABLE_VIRTUAL_TERMINAL_PROCESSING の activation成功を要求する。 いずれかの機能が利用できない場合、TUIのエスケープシーケンスは一切 出力されない; 警告がstderrに書き込まれ、fix は既存の行指向のCLI 確認フローを通じて継続する。
  • TUIのために変更されたすべてのコンソールモードはRAIIの端末セッションが 所有し、通常の復帰、キャンセル、例外、部分的な初期化失敗で復元される - 復元は最大1回のみ実行される。セッションは CTRL_CLOSE_EVENT/CTRL_LOGOFF_EVENT/CTRL_SHUTDOWN_EVENT(C++の 巻き戻しだけでなく)でも復元する。これらはデストラクタが通常は実行され ない場合でも配信されるためである(ウィンドウを閉じる、ログオフ、 シャットダウン); ハンドラは復元後常に FALSE を返し、デフォルトのOS 処理が引き続き進行するようにする。フォーカスがチェックリストにある間、 Ctrl+Cはこのコンソール制御ハンドラのパスによって意図的に処理されない - 下記の端末所有権の注記を参照。

端末所有権の境界(stdoutのVT vs. 新しい端末セッション)

stdoutの ENABLE_VIRTUAL_TERMINAL_PROCESSING モードは引き続き Console が排他的に所有する(コンストラクタが1回有効化し、デストラクタが 1回復元する)。M6から変更なし。#58が導入する新しい端末セッションは3つだけを 所有する: stdinのコンソールモード、代替スクリーンの状態、カーソルの可視性。 stdout上で GetConsoleMode/SetConsoleMode を呼び出してはならない - 呼び出すと「元のモード」として Console がすでに変更した後の値を捕捉して しまい、復元順序が壊れる。Console は3つの読み取り専用アクセサ (vtEnabled()、stdinInteractive()、stdoutInteractive())を得る。 これにより端末セッションとディスパッチ層は、機能を再導出も再変更もせずに 問い合わせられる。

チェックリストの挙動

  • 衝突していない Missing と Broken の項目のみが選択可能である。
  • Ok、Mismatch、エイリアス衝突、有効なエイリアスを持たない実行ファイルは 決して選択可能な修復として提示されない。既存の警告と除外の挙動は そのまま残る。
  • すべての選択可能な項目は未チェックの状態で始まる。修復には明示的な Spaceキーの選択に続くEnterが必要である。
  • 上下でカーソルを移動し、Spaceで現在の項目をトグルし、Enterで現在の選択を 確定する。
  • Escape、Q、Ctrl+C は変更前にキャンセルし、選択された修復なしで成功を 返す。ここでのCtrl+CはSetConsoleCtrlHandler経由ではなく、キー イベントとして読まれる: チェックリストの入力モードは ENABLE_PROCESSED_INPUT をクリアするため、Ctrl+Cは、他のすべての キーを読む同じブロッキングの ReadConsoleInputW ループによって読まれる 通常の KEY_EVENT_RECORD(dwControlKeyState にCtrlビットが立った wVirtualKeyCode == 'C')として到着する - メインスレッドが決して来ない ライン指向読み取りを待って ReadConsoleInputW 内でブロックしている間に 自身のスレッドで発火する別の CTRL_C_EVENT としてではない。 (そのスレッド分離こそが、既存のバッチループのCtrl+Cハンドラ (src/cli/Dispatch.cpp の g_ctrlCRequested)が依存している障害 モードそのものである - メインスレッドがチェックの間に作業をしている 非対話的な修復ループには正しいが、キーの読み取りでブロックされている ループには誤りである。)SetConsoleCtrlHandler は、下記で説明する 修復ループフェーズの周辺で、チェックリストセッションが終了した後にのみ 登録される。
  • 選択項目なしでのEnterは成功するno-opである。
  • 長いリストはスクロールするビューポートを使う。コンソールのリサイズは カーソルや選択状態を失うことなくビューポートを再計算する。
    • リサイズ検出には、stdinの入力モードに ENABLE_WINDOW_INPUT を含む 必要がある(これがないと WINDOW_BUFFER_SIZE_EVENT は決して生成 されない)とともに、ENABLE_VIRTUAL_TERMINAL_INPUT を除外する 必要がある(VT入力を有効にするとキー/リサイズ信号がエスケープ シーケンスのストリームに変換され、WINDOW_BUFFER_SIZE_EVENT を 完全に抑制してしまう)。
    • マウスドラッグがテキストを選択してレンダラを停止させないよう、 ENABLE_QUICK_EDIT_MODE をクリアしなければならない; そのクリアが 有効になるのは ENABLE_EXTENDED_FLAGS も同じ SetConsoleMode 呼び出しで渡された場合のみである - ENABLE_EXTENDED_FLAGS を省略 すると quick-edit の変更は静かにno-opになる。
    • WINDOW_BUFFER_SIZE_EVENT.dwSize はスクリーンバッファサイズを 報告するのであって可視ウィンドウサイズではない。ビューポートの 高さ/幅として直接使ってはならない。ビューポートは GetConsoleScreenBufferInfo().srWindow(Bottom - Top + 1 行、 Right - Left + 1 列)から導出する。
    • stdin入力モードの完全な契約: ENABLE_WINDOW_INPUT | ENABLE_EXTENDED_FLAGS を設定; ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT | ENABLE_PROCESSED_INPUT | ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_QUICK_EDIT_MODE | ENABLE_MOUSE_INPUT をクリア。
  • パッケージ由来のテキストは、意図的なTUI制御シーケンスと組み合わせる前に サニタイズされる(sanitizeForDisplay())。
  • 確認だけでは変更を許可しない。選択された各項目は共有の修復バッチ エグゼキュータ(#60)に渡される。これはCLIが repairLink() 経由で 使うのと同じ最新の変更前検査を実行する。

端末セッションの初期化/終了順序

代替スクリーン制御(ESC[?1049h/l)はstdoutのVT処理が有効な場合にのみ 効果を持つため、初期化は厳密に順序付けられる: stdout VTを有効化(すでに Console が所有しており vtEnabled() で問い合わせる)→ 代替スクリーンに 入る → カーソルを隠す → 上記のstdin入力モードの変更を適用する。終了処理は これを正確に逆順にする: stdin入力モードを復元 → カーソルを表示 → 代替 スクリーンを出る。初期化の途中の失敗は、すでに成功したステップのみを 逆順に巻き戻してから機能を利用不可として報告する - 部分的な状態 (例: 代替スクリーンに入ったがstdinモードは未変更)を決して残さない。

進捗、サマリー、終了コード

選択が確定した後、チェックリストセッションは修復が始まる前に(上記の 終了順序に従って)端末を復元し、共有エグゼキュータが [current/total] alias: result の形式で永続的な進捗行を書き込む。 最終サマリーは以下を報告する:

  • 選択済み、処理済み、残数;
  • 作成済みと置換済み;
  • ドライランモードでの計画済み操作;
  • declined — 対話的な確認をユーザーが拒否した項目。これはドライランの planned outcomeとは異なる: 実際の(非ドライランの)修復を拒否する ことを、あたかも --dry-run がその計画を生成したかのように報告しては ならない。それは試みられなかった修復を、検査済みで計画済みのものと 誤って表現することになるためである。declined は終了コードに影響 しない(下記参照)。既存のJSONスキーマ(docs/adr-phase-5.md ADR-0022)は変更されない - declined はコンソール/TUIサマリーの カテゴリのみであり、スクリプトとの互換性を保つために新しいJSON フィールドにはしない;
  • スキップされた項目、具体的には最新の Ok 結果(SkippedOk)または 拒否されたミスマッチ(RefusedMismatch);
  • 失敗した項目; および
  • バッチが中断されたかどうか、そして何件の項目が未処理のまま残ったか。

修復ループフェーズ中(チェックリストセッションが終了しバッチが実行中の間、 チェックリスト自身のキー処理ではなく既存の SetConsoleCtrlHandler ベースの 仕組みを使う)のCtrl+Cは、現在の項目を完了させてから次の項目の前に停止する。 残った項目はサマリーでカウントされる。

終了コードの優先順位。この計画の原文から修正され、今はM7以前のCLIパスとは 異なる(TUIと非対話的な fix パスの両方に適用される意図的なCLI全体の 修正であり、新しいTUIコードだけではない):

  1. 権限不足 → 終了コード2。同じバッチで起きた中断や他の部分失敗より優先 される。
  2. それ以外で、中断または他の部分失敗 → 終了コード10。残数がゼロの中断 (最後の項目がすでに処理された後にCtrl+Cが観測された場合)は、 終了コードの目的では中断として報告されない - それが妨げられたはずの ものは何もないため - したがってこの分岐は remaining > 0 を要求する。 中断されたドライランバッチは、何も変更しなかったとしてもやはり10を 返す: 不完全なドライランレポートは、報告における部分的な失敗である。
  3. それ以外 → 終了コード0。完全な成功、変更前のキャンセル、空の選択、 成功した(完全で中断されていない)ドライランをカバーする。

実装の境界

#58 — 仮想端末出力を有効化する

  • stdoutコンソール、stdinコンソール、VT有効の各機能を、色の有効化とは 独立して、既存の Console クラス(新しい並行の機能型ではなく)上の 読み取り専用アクセサとして公開する - vtEnabled()、 stdinInteractive()、stdoutInteractive()。Console はすでに 内部でVT機能を計算しており(tryEnableVirtualTerminal())ローカル 変数に捨てている; #58はそれを保持する。
  • 注入可能な端末操作の縫い目(このコードベースがすでに使う既存の ConsoleOperations/SymlinkServiceOperations の縫い目パターンに 合わせる)と、stdin入力モード、代替スクリーン状態、カーソル可視性、 リサイズ/キーイベントのためのRAII端末セッションを追加する - 上記の「端末所有権の境界」で説明したとおりのスコープに厳密に従う。 stdoutのVTは引き続き排他的に Console の責務である。
  • 成功した activation、リダイレクション(stdinとstdoutを独立して)、 VT activationの失敗、部分的な初期化失敗とその逆順の巻き戻し、 --no-color(VT機能が影響されないことを確認する - これは新しいコード パスではなく既存の挙動の検証である)、正確に1回の復元、登録された close/logoff/shutdownハンドラによってトリガーされる復元をカバーする。

#59 — 修復チェックリストを実装する

  • 純粋でユニットテスト可能な選択状態モデル(Win32依存なし)と、 #58の端末セッションの上に構築された薄いWin32/VTレンダラを追加する。
  • 上記のstdin入力モード契約に従って、ナビゲーション、トグル、確認、 キャンセル、リサイズ、スクロールを実装する。
  • 既存のインベントリと修復パイプラインに選択を統合する (Missing/Broken にフィルタされた nonCollisionItems)。
  • 上記に文書化されたコマンド/オプションの衝突を、ArgParser.cpp に すでにある既存の --json-with-fix の衝突チェックと並べて強制する。
  • 現行の行指向のCLIパスを機能フォールバックとして保持し、フォールバック 時にはTUIのエスケープシーケンスを一切出力しない。

#60 — TUIの進捗と結果を表示する

  • CLIとTUIの両方のパスで使う1つの共有修復バッチエグゼキュータを抽出し、 構造化されたサマリー(declined を含むディスポジション別のカウント) を返し、上記の終了コードの決定を1か所で所有する。これにより、CLIの 非対話的な fix パスとTUIパスが互いに乖離できないようにする。
  • 修復の決定を重複させることなく、項目ごとの進捗と最終サマリーを報告する。
  • declined、ドライラン、権限失敗、一般的な失敗、残数ありの中断、 残数ゼロの中断を含むすべての結果カテゴリをカバーする。

検証とレビューのゲート

各実装プルリクエストは以下を満たさなければならない:

  • /W4 /WX で Debug|x64、Release|x64、Debug|ARM64、Release|ARM64 をビルドする;
  • DebugとReleaseのx64 MSTestスイートを実行し、実際の結果を報告する;
  • ARM64ホストでテストしない限り、クロスビルドのみとして記述する;
  • すべての新しい状態遷移とWin32操作の縫い目に焦点を絞ったユニットテストを 追加する;
  • 対話的なキー(キーとして読まれるCtrl+Cを含む。制御ハンドライベントとして ではなく)、リサイズ、ドライラン、no-color、機能フォールバック、 衝突/ミスマッチの除外、修復ループ中のCtrl+C、declined vs. planned の報告、暫定のPackages/Linksディレクトリに対する最終サマリーを手動で 検証する;
  • 新しいサードパーティ依存を一切追加しない;
  • Copilotレビューを要求し、実行可能なフィードバックを取り込み、対応した 各コメントに返信し、そのレビュースレッドを解決する; および
  • レビュー可能な状態になった後もマージせずに残す。

各プルリクエストはそのsub-issueに対して Closes を使用し、メンテナが 最終的にマージした時点でissueがクローズされるようにする。それまでは、 #58、#59、#60、親の#9は開いたままとする。

実装が運ぶドキュメント

  • docs/adr-phase-6.md: 3つのスタックされたプルリクエストが同じADR セクションを同時に編集するのを避けるため、共有の1つのADRではなく sub-issueごとに1つのADR - ADR-0026(#58、端末所有権とフォールバック)、 ADR-0027(#59、選択/オプション契約)、ADR-0028(#60、共有エグゼキュータ と修正済みの終了コード優先順位)。ADR-0026は#58専用であり、M8の issue #113(--tui/--verbose/--quiet)は、この改訂が見つけた番号の 衝突を避けるためADR-0026ではなくADR-0029を使う。
  • docs/TODO.md: 対応するsub-issueごとにM7の項目を1つチェックする。
  • docs/PLAN.md: TUIのDefinition-of-Done項目は#60でのみマークする。
  • docs/task.md: すべてのsub-issueについて実装と検証の証跡を記録する。

正典ドキュメントとコードコメントは英語のままである。ローカライズされた *_ja.md ファイルは実装のインプットではなく、変更されない。


実装後の改訂 — チェックリストに実際に載るもの (2026-09-20)

実装された M7 のチェックリストは、ADR-0027 決定 4 に従い Missing と Broken の候補 だけ を表示していた。issue #179 はそれでは不十分であることを示した。Ok なリンクが 20 件、Mismatch が 1 件というホストでは fix --tui がチェックリストをまったく出さず、 無言で非対話ループに落ちてしまい、注意が必要な唯一の候補を隠していた。

現在は Mismatch を選択不可の情報行として表示する。現行の契約は次のとおり。 「fix は Mismatch を決して修復しない」という点は忘れられやすいので、ここに残しておく。

LinkStatus チェックリストに出る? 選択可能? fix の動作
Missing 出る 可 — チェックが作成への同意 シンボリックリンクを作成する
Broken 出る 可 — チェックが置換への同意 削除して作り直す
Mismatch 出る ([-] ... [cannot repair]) 不可 何もしない — refused (mismatch) を報告
Ok 出ない — 何もしない — already Ok を報告

エイリアス衝突はチェックリストより手前で除外され、到達しない (ADR-0021)。

Mismatch は今後も修復しない。Links\ 配下のエントリが通常ファイル、シンボリック リンク以外のリパースポイント、または 別の 既存ファイルを指すシンボリックリンクである という状態であり、置き換えれば実際にそこにあるものを破壊してしまう (ADR-0014、ADR-0016)。 --force / --replace-mismatch に相当するオプションは存在せず、その判断は維持する。 行を表示するのは、手動対応が必要な対象をユーザーが把握できるようにするためであって、 ここで操作させるためではない。

同じ変更で 2 点を併せて修正した。「表示するものが何もない」場合に stderr へ警告を出すように した (ADR-0027 決定 5 は元々これを規定していたが、実装されていたのは端末能力の分岐だけ だった)。また、グループ化プレビューの抑止を、--tui が指定されただけのときではなく、 チェックリストが実際に起動したときだけに限定した。

詳細は --tui の表示内容とパス表示 と docs/adr-phase-10.md の ADR-0047 を参照。

Clone this wiki locally