diff --git a/README.md b/README.md index ec54fad..0f47afe 100644 --- a/README.md +++ b/README.md @@ -62,10 +62,12 @@ npm run tauri dev 作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。 -右の参加者パネルに、部屋にいる参加者と、まだ起動していないアカウント(「未起動」)が並びます。その下にセッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。発言の色は参加者ごとに違い、名簿の点と同じ色です。色は選んだものが優先され、選んでいなければ自分は accent(青)、他の参加者は名前から決まります。名簿では自分の行に「(あなた)」が付きます。宛先に選べるのは部屋に居る参加者だけで、未起動のアカウントは出ません。 +右の参加者パネルに、部屋にいる参加者と、まだ起動していないアカウント(「未起動」)が並びます。その下に「端末」の列があり、この画面が端末を持っているアカウントが一行ずつ出ます。行を押すとその端末が診断面に出て、行の「終了」でそのアカウントのセッションを終了できます。終了は二度押しです——一度目でボタンが「本当に終了」に変わり、数秒で戻ります。さらに下にセッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。部屋ソケット以外は、選んでいる端末のアカウントのものです。発言の色は参加者ごとに違い、名簿の点と同じ色です。色は選んだものが優先され、選んでいなければ自分は accent(青)、他の参加者は名前から決まります。名簿では自分の行に「(あなた)」が付きます。宛先に選べるのは部屋に居る参加者だけで、未起動のアカウントは出ません。 「診断」を開くと**起動した CLI の端末**が出ます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。 +端末はアカウントごとに別です。複数のセッションを動かしても出力が混ざることはなく、キー入力とペーストは**今見えている端末**のセッションへ届きます。別のアカウントを見てから戻っても、それまでの出力は残っています。終了したアカウントの端末も、別のアカウントを選ぶまで残ります(なぜ落ちたかは、落ちる直前の出力にしか書かれていないためです)。 + この端末は表示と操作のためのものです。ここに映る内容が部屋の発言になることはありません。部屋に並ぶのは channel 経由の発言だけです。 アカウントを選び「セッション参加」を押すと、次の 2 つが行われます。 diff --git a/docs/0-requirements.md b/docs/0-requirements.md index f9da47f..cb6c57d 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -247,7 +247,8 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 宣言色(`hello` の `hue` / `room_join` の `hue`。宣言 > 自分の accent > 名前からの導出) - チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者から選ぶ宛先) - 参加者パネル(名簿、参加者ごとの色、部屋ソケット、セッションの生死と終了コード、接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ) -- 診断面(CLI の端末表示と入力) +- アカウントごとの端末(パネルの端末の列で選ぶ、行からの終了、切り替えで残るスクロールバック) +- 診断面(アカウントごとの CLI の端末表示と入力) - Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD ### 実機で確認済み(2026-08-21) @@ -262,6 +263,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 床を入れた後の同時発話の実機観測。参加者間で発言が届く経路、起動ごとの名乗り、参加者ごとの登録鍵、床の判定はいずれも実装済みである。2026-08-22 の実測(2 セッションが互いの発言を受け取らない / 2 セッションがどちらも `Claude Code` を名乗る)は #39 と #40 の修正前のものであり、2026-08-23 の二重応答(#47 の premise)は床の実装前のものである。いずれも修正後の再計測は済んでいない。 - 順番の付与(弾くだけでなく「あなたは N 人め」を返す形)。戻り値の形は段差を残していないが、二重計算が痛むと測れてから判断する。 - アカウント面の実機確認。作成・改名・削除、オフライン表示、二重起動の拒否、改名後の `.mcp.json` 登録の追随はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。 +- アカウントごとの端末と終了の実機確認。2 セッションでの出力の分離、入力の宛先、切り替え後のスクロールバック、終了後の席の解放と再起動はいずれも実装済みであり、CI の型検査は通っているが、実機での操作は未確認である。 - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) @@ -298,6 +300,16 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 端末はこのパネルへ畳まない。設計キャンバスは右下へ同居させているが、パネルの幅では CLI が枠を描けない(下記「診断面」)。 +**名簿の下に、端末の列を持つ。** この画面が端末を持っているアカウントが一行ずつ並び、行をクリックすると診断面の表示がその端末へ切り替わる。行には終了が付く。 + +名簿の第二の一覧ではない。答える問いが違う。名簿は「誰が部屋に居るか」であり、出所は部屋の名簿で、鍵は参加者が接続してきたコネクションである。端末の列は「この画面がどのセッションの端末を持っているか」であり、鍵はアカウント id である。集合も一致しない——別の場所から参加した参加者はここに端末を持たず、終了した直後のセッションは端末を持っていて席を持たない。 + +行の鍵をアカウント id に置くのは、行に操作を載せるための条件でもある。端末の表示・入力・終了はいずれも一つのアカウントに対する操作であり、名前で突き合わせると、二つのアカウントが同じ名前を名乗った時点で別の行に当たる(上記「アカウント」)。 + +端末を持たないアカウントは、この列に行を持たない。未起動のアカウントに出せる端末の操作は無い。 + +パネル下部のセッションの値(接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ・生死)は、選択されている端末のものである。最後に起動したセッションのものではない。部屋ソケットだけは部屋自身の値であり、選択に依らない。 + ### 診断面 会話面ともパネルとも別枠として、CLI の出力を表示する診断面を持つ。既定は折りたたみで、セッションの起動失敗や異常終了を検出したときは自動で開く。 @@ -315,8 +327,22 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 端末をパネルへ畳まないのはこのためである。CLI が枠を描くには相応の桁数と行数が要り、名簿と同じ幅の列では TUI の再描画が成立しない。設計キャンバスは右下への同居を描いているが、あれは配置の絵であって寸法の指定ではない。 +**端末はアカウントごとに持つ。** 診断面は一つの枠であり、その中に稼働中のアカウントの数だけ端末が立つ。選択されている一つだけが見え、残りは破棄せず隠す。隠れている端末にも出力は届き続けるため、別のアカウントを見てから戻ると、それまでのスクロールバックは残っている。 + +一つの端末を共有しない。共有すると、稼働中の全セッションの出力が一枚の画面へ書き込まれる。CLI はカーソル移動と部分再描画で出力するため、二つの再描画が重なった後の画面から片方を分離することはできない。「最後に起動したものが見える」のではなく、全部が重なる(#57)。 + +終了した端末は、その場では消さない。落ちる直前の出力が、なぜ落ちたかを読む唯一の材料である。別のアカウントが選ばれた時点で捨てる。同じアカウントを再起動した場合も、前回の端末は新しい端末に置き換わる。 + +**終了はアカウントごとに行う。** 端末の列の行から、そのアカウントのセッションを終了する。席の解放は別途要らない——`RoomSeats` は稼働の有無を PTY へ問うため、終了すれば席は空き、そのまま再起動できる(上記「アカウント」)。 + +終了は取り消せない操作であり、表示を切り替えるだけの操作の隣に並ぶ。無地のクリック一つでは実行しない。一度目のクリックは武装するだけで、ボタンの文言と見た目が変わり、数秒で武装は解ける。二度目のクリックが実行する。 + +`window.confirm` を使わないのは、ホストが答えなかった場合の失敗の向きがどちらも誤りであるためである。答えを待たない実装ではボタンが黙って何もしない側へ倒れ、答えを既定で肯定と読む実装(アカウント削除が取っている側)では一度のクリックで落ちる。画面側で武装状態を持てばどちらも起きず、どちらの状態にいるかをボタン自身が表示する。 + 端末はセッションへの入力路でもある。キー入力はそのまま PTY へ渡り、ウィンドウのリサイズは CLI 側へ伝わる。 +**入力の宛先は、今見えている端末のセッションである。** キー入力もペーストも、その端末が属するセッションへ送る。「最後に起動したセッション」は宛先にしない。画面に見えている端末と、打ち込まれる端末が一致しない状態は、表示の混線より実害が大きい(#57)。リサイズも同様に、見えている端末のセッションへだけ伝える。 + これも会話面とは別の axis にある。CLI はフォルダごとに初回の信頼確認を出すため、この経路が無いとどの作業ディレクトリでもセッションは起動せず、部屋が立ち上がること自体が構造的に不可能になる。 信頼確認や権限確認をアプリが自動で答えることはしない。それらはセキュリティ上の確認であり、判断は人間が行う。アプリが負うのは、人間が答えられる経路を用意することであって、代わりに答えることではない。 @@ -411,7 +437,7 @@ CI が実行するもの: 上流から順に確認する。各段は下流の前提であるため、順序を飛ばさない。 1. **部屋ソケットが待ち受けているか。** 右の参加者パネルの部屋ソケットの行を見る。`127.0.0.1:` が accent 色で出ていなければ以降はすべて不成立。 -2. **セッションが生きているか。** 同じパネルのセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。理由はタイトルバーの「診断」を開いた先の端末に残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。 +2. **セッションが生きているか。** 同じパネルのセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。この行は端末の列で選択されているアカウントのものであるため、複数動かしている場合はまず対象の行を選ぶ。理由はタイトルバーの「診断」を開いた先の端末に残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。 3. **サイドカーが接続したか。** パネルの名簿で、そのアカウントの行から「未起動」が消える。名簿にはアカウントが起動していなくても並ぶため、名前が出ていること自体は接続の根拠にならない。自分(人間)も発言前から載っている。接続していない場合は端末に `room socket: connected as ""` があるかを見る。 4. **サイドカー単体が壊れていないか。** `npm run sidecar:test` を実行する。偽の部屋ソケットを立てて両面を駆動するため、通ればサイドカーではなくアプリ側の問題に絞れる。 5. **`.mcp.json` の登録が効いているか。** セッションの作業ディレクトリの `.mcp.json` に `pullcept-room--` が入っているかを見る。鍵はアカウント id から決まるため名前では引けない。どのアカウントのものかはエントリ内の `PULLCEPT_AGENT_NAME` で読む。固定の `pullcept-room` は前回起動の残りとして掃除される側である。`--mcp-config` によるファイル渡しでは channel 側が名前を解決できないため、正式登録以外の経路は成立しない。 @@ -424,7 +450,7 @@ CI が実行するもの: 2 枚のアートボードを並べ、その差分をロードマップとして読む形にしてある。 -- **次の一手** — 現在の実装に参加者パネルと宛先を足した、実装可能な範囲。両方とも実装済みである。この 1 枚と実装の差は、操作ボタン(端末 / リサイズ / 終了)、宛先の見た目(削除可能なチップ)、#40 でランチャとタイトルバーに入った名乗り(名前欄と色の選択)、そして #53 のアカウント(作成・編集・削除、名簿のオフライン表示)である。宛先は `` で機能的に等価であり、形の差は追っていない。名乗りとアカウントの側はキャンバスが実装より古く、差は #46 で追う。 - **全体像** — ルーム一覧 / ダイレクト / 参加者・セッション状態パネル 色と寸法は `src/styles.css` の実値をそのまま使っている(`--accent: #3a6ea5` / `--surface: #f4f4f7` / 角丸 4・6px / system-ui + Yu Gothic UI)。参加者ごとの色は accent と同じ彩度・明度で色相だけ変えたもの。絵と実装が同じトークンの上に乗るため、キャンバスの値をそのまま実装へ移せる。 @@ -434,7 +460,7 @@ CI が実行するもの: ### 決まったこと - 会話面はメッセージ単位を保つ。端末は診断面側に置き、会話面には置かない。 -- 参加者パネルを持つ(実装済み。上記「参加者パネル」)。セッションの接続方法・起動コマンド・作業ディレクトリ・開始時刻を出す。操作(端末 / ミュート / 終了)は未実装であり、キャンバス側にのみある。 +- 参加者パネルを持つ(実装済み。上記「参加者パネル」)。セッションの接続方法・起動コマンド・作業ディレクトリ・開始時刻を出す。操作のうち端末と終了は実装済みで、アカウントごとの行に載っている。ミュートは未実装であり、キャンバス側にのみある。 - 発言に宛先を持たせる(実装済み。入力欄の左で参加者から選ぶ。既定は全体宛)。 - 参加者ごとの色(実装済み。accent と同じ明度・彩度で色相のみ)。色相は宣言でき、宣言が導出に優先する(上記「参加者パネル」の梯子)。 - 名前と色は「この部屋での自分は誰か」の宣言である(実装済み。人間はタイトルバーで参加の時点に、セッションはアカウントの属性として)。 diff --git a/index.html b/index.html index 46cada0..709718a 100644 --- a/index.html +++ b/index.html @@ -80,6 +80,10 @@ than watched — the socket, the session, how it was launched — moved to the participant panel, where they are on screen without opening anything. --> + @@ -126,6 +130,29 @@

参加者

  • 参加者なし
  • + + + +
    部屋ソケット
    確認中
    diff --git a/src/main.ts b/src/main.ts index fd9bb15..63e9667 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,7 +11,7 @@ // speech. The rejected design is the one where the app parses CLI output to // find messages (docs/0-requirements.md); showing the CLI is not that. import { invoke } from "@tauri-apps/api/core"; -import { listen } from "@tauri-apps/api/event"; +import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import { readText } from "@tauri-apps/plugin-clipboard-manager"; import { Terminal } from "@xterm/xterm"; import { FitAddon } from "@xterm/addon-fit"; @@ -161,6 +161,8 @@ const dirEl = document.getElementById("session-dir") as HTMLElement; const startedEl = document.getElementById("session-started") as HTMLElement; const windowEl = document.getElementById("session-window") as HTMLElement; const terminalEl = document.getElementById("terminal") as HTMLElement; +const terminalsEl = document.getElementById("terminals") as HTMLElement; +const terminalsSectionEl = document.getElementById("terminals-section") as HTMLElement; const cwdEl = document.getElementById("session-cwd") as HTMLInputElement; const optionsEl = document.getElementById("launch-options") as HTMLInputElement; const previewEl = document.getElementById("launch-preview") as HTMLElement; @@ -178,8 +180,6 @@ let accounts: Account[] = []; let seated = new Set(); /** Everyone in the room, this screen's person included. */ let participants: Participant[] = []; -/** The session the terminal is attached to, once one is running. */ -let activePtyId: string | null = null; /** Prefill for an account that has never been given a working directory. */ let homeDir = ""; /** @@ -194,7 +194,14 @@ let homeDir = ""; */ let lastSeenId: string | null = null; -const terminal = new Terminal({ +/** + * The emulator options every session's terminal is opened with. + * + * One set for all of them, so that two sessions on this screen are two of the + * same kind of thing and a difference between their panes says something about + * the sessions rather than about the panes. + */ +const TERMINAL_OPTIONS = { cursorBlink: true, fontSize: 13, fontFamily: 'ui-monospace, "Cascadia Mono", Consolas, monospace', @@ -203,8 +210,56 @@ const terminal = new Terminal({ // what the previous line-appending pane did (#24). convertEol: false, scrollback: 5000, -}); -const fitAddon = new FitAddon(); +}; + +/** How long a 終了 stays armed before it falls back to its resting state. */ +const END_ARM_MS = 4000; +/** + * How long an armed 終了 refuses to act. + * + * The armed button is wider than the resting one and grows under the pointer, + * so a double-click lands both clicks on it: without this, one slip of the + * finger arms and ends in a single gesture, which is the shape the two clicks + * exist to rule out. Short enough that a deliberate second click never waits. + */ +const END_SETTLE_MS = 400; + +/** + * One account's terminal: the session's output, its scrollback, and the way in. + * + * One per account, never one shared. A shared emulator was handed the output of + * every running session at once, and a TUI's repaint cannot be told from + * another's after the two have been written into one screen — the panes were + * not taking turns, they were overlapping (#57). Separate emulators also decide + * where input goes: this view writes to `ptyId` and to nothing else, so what is + * typed reaches the session that is being looked at. + */ +interface SessionView { + /** The account this terminal belongs to. The identity, so a rename is free. */ + accountId: string; + /** Empty until the launch returns; nothing may be written before then. */ + ptyId: string; + /** The name the account had at launch, for a view whose account is gone. */ + name: string; + command: string; + cwd: string | null; + startedAt: string; + term: Terminal; + fit: FitAddon; + host: HTMLElement; + unlisten: UnlistenFn[]; + /** How the session ended, or null while it is still running. */ + ended: string | null; +} + +/** The terminals this screen holds, by account id, in launch order. */ +const views = new Map(); +/** The account whose terminal is on the glass, or null when none is. */ +let shownAccount: string | null = null; +/** The account whose 終了 is armed. Its next click is the one that ends it. */ +let armedEnd: string | null = null; +let armedTimer = 0; +let armedAt = 0; function status(text: string, kind: "info" | "error" = "info"): void { statusEl.textContent = text; @@ -215,27 +270,43 @@ function revealDiagnostics(): void { diagnosticsEl.hidden = false; toggleEl.setAttribute("aria-expanded", "true"); // The container has no size while hidden, so the fit has to wait for layout. - requestAnimationFrame(() => fitTerminal()); + requestAnimationFrame(() => fitShown()); } -function fitTerminal(): void { - if (diagnosticsEl.hidden) return; +/** The terminal currently on the glass, or null when none is. */ +function shownView(): SessionView | null { + return shownAccount === null ? null : (views.get(shownAccount) ?? null); +} + +/** What a view is called now — its account's current name, renames included. */ +function viewName(view: SessionView): string { + return accounts.find((account) => account.id === view.accountId)?.name || view.name; +} + +/** + * Lay out the terminal that is showing, and tell its session the new size. + * + * Only that one. A hidden pane has no size to fit against, and a session told + * it has zero columns draws for a window it does not have. + */ +function fitShown(): void { + const view = shownView(); + if (!view || diagnosticsEl.hidden) return; try { - fitAddon.fit(); + view.fit.fit(); } catch { // A fit against a zero-sized container is not worth a message. return; } - if (activePtyId !== null) { - showWindowSize(); - void invoke("resize_pty", { - id: activePtyId, - cols: terminal.cols, - rows: terminal.rows, - }).catch(() => { - // The session may have exited between the fit and the call. - }); - } + showWindowSize(); + if (view.ptyId === "" || view.ended !== null) return; + void invoke("resize_pty", { + id: view.ptyId, + cols: view.term.cols, + rows: view.term.rows, + }).catch(() => { + // The session may have exited between the fit and the call. + }); } /** @@ -245,7 +316,8 @@ function fitTerminal(): void { * number it was given is the one thing that says which of the two it is. */ function showWindowSize(): void { - windowEl.textContent = `${terminal.cols}×${terminal.rows}`; + const view = shownView(); + windowEl.textContent = view ? `${view.term.cols}×${view.term.rows}` : "—"; } /** Render saved arguments back into an editable line. */ @@ -639,47 +711,339 @@ async function send(): Promise { } /** - * Follow a launched session until it dies. + * Show what the terminal on the glass was launched from. * - * A session that exits on startup is the failure mode with no other witness: - * the room simply stays empty. Without this the screen is identical whether - * the CLI is running or was never there. + * Read off the shown view rather than written once at launch: with a terminal + * per account these values answer "what is this pane", and a pane switched away + * from that left its command on screen would be answering for the wrong one. + * They survive the session's exit — the question is what ran. */ -async function followSession(account: Account, started: StartedSession): Promise { - const name = account.name; - sessionStateEl.textContent = `${name} 起動中`; - sessionStateEl.dataset.kind = "ok"; - activePtyId = started.pty_id; - - // How this session was launched, on the panel rather than in the person's - // memory: a session that is answering nothing is read against the directory - // and the command it actually got, not against the ones that were intended. - // They stay on screen after the session exits — the question is what ran. +function renderSessionFacts(): void { + const view = shownView(); + if (!view) { + sessionStateEl.textContent = "未起動"; + sessionStateEl.dataset.kind = "info"; + transportEl.textContent = "—"; + commandEl.textContent = "—"; + dirEl.textContent = "—"; + dirEl.title = ""; + startedEl.textContent = "—"; + windowEl.textContent = "—"; + return; + } + const name = viewName(view); + sessionStateEl.textContent = view.ended === null ? `${name} 起動中` : `${name} 終了(${view.ended})`; + sessionStateEl.dataset.kind = view.ended === null ? "ok" : "error"; transportEl.textContent = "PTY"; - commandEl.textContent = account.command; - dirEl.textContent = account.cwd ?? "—"; - dirEl.title = account.cwd ?? ""; - startedEl.textContent = shortTime(started.started_at); + commandEl.textContent = view.command; + dirEl.textContent = view.cwd ?? "—"; + dirEl.title = view.cwd ?? ""; + startedEl.textContent = view.startedAt === "" ? "—" : shortTime(view.startedAt); showWindowSize(); +} - await listen(`pty-data-${started.pty_id}`, (event) => terminal.write(event.payload)); - await listen(`pty-exit-${started.pty_id}`, (event) => { - const code = event.payload; - const detail = code === null ? "終了コード不明" : `終了コード ${code}`; - sessionStateEl.textContent = `${name} 終了(${detail})`; - sessionStateEl.dataset.kind = "error"; - status(`${name} が終了しました(${detail})。診断を確認してください。`, "error"); - activePtyId = null; - // The seat this account held is free the moment its session ends, so the - // panel says 未起動 again and the account can be started once more. - void refreshSeats(); - revealDiagnostics(); +/** + * Draw the panel's terminal list. + * + * Not the roster, and not a second copy of it. The roster answers who is in the + * room and is keyed on the connection each participant arrived on; this answers + * which sessions this screen is holding a terminal for, and is keyed on the + * account id. The two memberships genuinely differ: a participant who joined + * from somewhere else has no terminal here, and a session that has just exited + * has a terminal and no seat. Keying this list on the account id is also what + * lets a row carry an operation at all — a name match would tie the wrong row + * as soon as two accounts answer to one name (#53). + */ +function renderTerminalList(): void { + terminalsEl.replaceChildren(); + terminalsSectionEl.hidden = views.size === 0; + + for (const view of views.values()) { + const account = accounts.find((candidate) => candidate.id === view.accountId); + const name = viewName(view); + const row = document.createElement("li"); + row.className = "term-row"; + row.style.setProperty("--speaker", speakerColor(name, account?.hue ?? null, false)); + if (view.accountId === shownAccount) row.classList.add("shown"); + if (view.ended !== null) row.classList.add("ended"); + + // The whole line picks the terminal, so choosing which session to watch is + // one click on the name of it rather than a control beside the name. + const pick = document.createElement("button"); + pick.type = "button"; + pick.className = "pick"; + pick.setAttribute("aria-pressed", String(view.accountId === shownAccount)); + + const dot = document.createElement("span"); + dot.className = "dot"; + + const who = document.createElement("span"); + who.className = "who"; + who.textContent = name; + who.title = name; + + const note = document.createElement("span"); + note.className = "note"; + note.textContent = view.ended === null ? "稼働中" : "終了"; + + pick.append(dot, who, note); + pick.addEventListener("click", () => { + revealDiagnostics(); + showView(view.accountId); + view.term.focus(); + }); + row.appendChild(pick); + + // No 終了 before the launch has returned an id: there is no session to end + // yet, and a kill aimed at an empty id reports success having done nothing. + if (view.ended === null && view.ptyId !== "") row.appendChild(endButton(view, name)); + terminalsEl.appendChild(row); + } +} + +/** + * The 終了 control, which takes two clicks. + * + * Ending a session cannot be undone, and this control sits in a list whose + * other click merely changes which pane is showing. One plain click away from a + * harmless neighbour is how a slip ends a session that was mid-answer, so the + * first click only arms: the button changes what it says and how it looks, and + * the second click within a few seconds is the one that acts. + * + * Two clicks rather than `window.confirm`, which the account delete beside this + * uses. That dialog answers on the host's terms, and the two ways it can fail + * here are both wrong: a host that answers nothing either makes the button + * silently dead or — the bias the delete chose — makes a single click destroy. + * An arm state held on this side has neither failure, and the button says which + * state it is in rather than a modal saying it elsewhere. + */ +function endButton(view: SessionView, name: string): HTMLButtonElement { + const armed = armedEnd === view.accountId; + const end = document.createElement("button"); + end.type = "button"; + end.className = armed ? "end armed" : "end"; + end.textContent = armed ? "本当に終了" : "終了"; + end.title = armed + ? `もう一度押すと ${name} のセッションを終了します。` + : `${name} のセッションを終了する`; + end.addEventListener("click", () => { + if (!armed) { + armEnd(view.accountId); + return; + } + // A click that arrives inside the settle window is the tail of the gesture + // that armed it, not an answer to it. Ignored, arm left standing. + if (Date.now() - armedAt < END_SETTLE_MS) return; + void endSession(view); }); + return end; +} + +/** Put one account's 終了 into its armed state, and let the arm lapse. */ +function armEnd(accountId: string): void { + window.clearTimeout(armedTimer); + armedEnd = accountId; + armedAt = Date.now(); + // The arm expires on its own. A button left saying 「本当に終了」 for the rest + // of a session is one that an unrelated click, minutes later, fires. + armedTimer = window.setTimeout(() => { + armedEnd = null; + renderTerminalList(); + }, END_ARM_MS); + renderTerminalList(); +} + +function disarmEnd(): void { + window.clearTimeout(armedTimer); + armedEnd = null; +} + +/** + * End one account's session. + * + * The seat is released by the session ending, not by this call: `RoomSeats` + * reads liveness off the PTY, so the account is offline again and startable the + * moment the process is gone (#53). The view stays until another account is + * chosen, because what it last printed is the only account of how it ended. + */ +async function endSession(view: SessionView): Promise { + disarmEnd(); + const name = viewName(view); + try { + await invoke("kill_pty", { id: view.ptyId }); + } catch (err) { + renderTerminalList(); + status(`${name} を終了できませんでした: ${err}`, "error"); + return; + } + status(`${name} を終了しました。`); + // The exit event marks the view ended and redraws the row; this call only + // says the kill was delivered. + await refreshSeats(); + renderTerminalList(); +} + +/** + * Give an account its own terminal and put it on the glass. + * + * Made before the launch, because the CLI's first paint is laid out for the + * size this pane reports and there is nothing else to measure. + */ +function openView(account: Account): SessionView { + // A relaunch replaces the previous run's pane. Two panes for one account + // would be two rows under one name, and the row is what the operations hang + // on; the scrollback that goes with it is the one the person just decided to + // start over from. + discardView(views.get(account.id)); + + const host = document.createElement("div"); + host.className = "term"; + terminalEl.appendChild(host); + + const term = new Terminal({ ...TERMINAL_OPTIONS }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(host); + term.options.theme = terminalTheme(); + + const view: SessionView = { + accountId: account.id, + ptyId: "", + name: account.name, + command: account.command, + cwd: account.cwd, + startedAt: "", + term, + fit, + host, + unlisten: [], + ended: null, + }; + + term.onData((data) => { + // This view's own session, never "the session that started last". The + // terminal being typed into is the one on the glass, and the two were not + // the same thing while one `activePtyId` stood for both (#57). + if (view.ptyId === "" || view.ended !== null) return; + void invoke("write_pty", { id: view.ptyId, data }).catch((err) => { + status(`セッションへ送れませんでした: ${err}`, "error"); + }); + }); + + // The webview does not deliver a native paste to xterm, so Ctrl+V is bridged + // explicitly. preventDefault stops the input arriving twice. + term.attachCustomKeyEventHandler((event) => { + const isPaste = + event.type === "keydown" && + (event.ctrlKey || event.metaKey) && + !event.altKey && + (event.key === "v" || event.key === "V"); + if (!isPaste) return true; + + event.preventDefault(); + void readText().then((text) => { + // Same destination as a keystroke: the pasted text goes to this pane's + // session, so a paste cannot land in a session that is not on screen. + if (text && view.ptyId !== "" && view.ended === null) { + void invoke("write_pty", { id: view.ptyId, data: text }); + } + }); + return false; + }); + + views.set(account.id, view); + showView(account.id); + return view; +} + +/** Close one terminal for good: its listeners, its emulator, its scrollback. */ +function discardView(view: SessionView | undefined): void { + if (!view) return; + for (const off of view.unlisten) off(); + view.unlisten = []; + view.term.dispose(); + view.host.remove(); + views.delete(view.accountId); + if (shownAccount === view.accountId) shownAccount = null; +} + +/** + * Put one account's terminal on the glass, and take the others off it. + * + * Hidden, not discarded: a session keeps running while another is being + * watched, and its output keeps arriving into its own emulator, so switching + * back finds the scrollback where it was left. + * + * A terminal whose session has ended is the exception, and it is discarded here + * rather than at the moment it died — what a session printed on its way out is + * the only account of why, and choosing another account is the person saying + * they have read it (#57). + */ +function showView(accountId: string | null): void { + disarmEnd(); + for (const view of [...views.values()]) { + if (view.ended !== null && view.accountId !== accountId) discardView(view); + } + shownAccount = accountId; + for (const view of views.values()) { + view.host.hidden = view.accountId !== accountId; + } + renderTerminalList(); + renderSessionFacts(); + fitShown(); + // A pane that was `display: none` kept filling its buffer and painted + // nothing, so coming back to it has to repaint from the buffer. The fit above + // does that only when the measured size changed, and returning to a pane the + // same size as the one just left is exactly when it did not. + const shown = shownView(); + if (shown) shown.term.refresh(0, shown.term.rows - 1); +} + +/** + * Follow a launched session until it dies. + * + * A session that exits on startup is the failure mode with no other witness: + * the room simply stays empty. Without this the screen is identical whether + * the CLI is running or was never there. + */ +async function followSession(view: SessionView, started: StartedSession): Promise { + view.ptyId = started.pty_id; + view.startedAt = started.started_at; + renderTerminalList(); + renderSessionFacts(); + + // Both listeners are this view's, and are dropped with it. The shared + // terminal subscribed once per launch and unsubscribed never, which is how + // every running session ended up writing into one pane (#57). + view.unlisten.push( + await listen(`pty-data-${started.pty_id}`, (event) => view.term.write(event.payload)), + ); + view.unlisten.push( + await listen(`pty-exit-${started.pty_id}`, (event) => { + const code = event.payload; + const detail = code === null ? "終了コード不明" : `終了コード ${code}`; + view.ended = detail; + // Nothing more will arrive on this pty. The view lives on for what it + // has already printed, not for anything it is still waiting to hear. + for (const off of view.unlisten) off(); + view.unlisten = []; + const name = viewName(view); + status(`${name} が終了しました(${detail})。診断を確認してください。`, "error"); + // The seat this account held is free the moment its session ends, so the + // panel says 未起動 again and the account can be started once more. + void refreshSeats(); + renderTerminalList(); + if (shownAccount === view.accountId) { + renderSessionFacts(); + revealDiagnostics(); + } + }), + ); // The first thing a session shows is a question, so the pane that carries // the answer opens with it rather than waiting for a failure. revealDiagnostics(); - terminal.focus(); + if (shownAccount === view.accountId) view.term.focus(); } async function startSession(): Promise { @@ -721,29 +1085,36 @@ async function startSession(): Promise { saveAccounts(); // Size the PTY to the terminal that will display it, so the CLI's first - // paint is not laid out for a window it does not have. + // paint is not laid out for a window it does not have. The pane is revealed + // first because a hidden container has no size to measure. + // + // What was on the glass is kept, so a launch that fails can put it back + // rather than leaving a blank pane where a running session had been. + const previous = shownAccount; revealDiagnostics(); - fitAddon.fit(); + const view = openView(account); startEl.disabled = true; status(`${name} を起動しています…`); try { const started = await invoke("start_session", { account, - cols: terminal.cols, - rows: terminal.rows, + cols: view.term.cols, + rows: view.term.rows, }); status(`${name} を起動しました。${started.mcp_config} に登録済み。`); await refreshSeats(); - await followSession(account, started); + await followSession(view, started); } catch (err) { + // Nothing was spawned, so this terminal has nothing to show and no session + // to end. It goes, and the failure stands in the status line, which carries + // the app's own reason rather than the word 起動失敗. + discardView(view); + showView(previous !== null && views.has(previous) ? previous : ([...views.keys()].pop() ?? null)); status(`${name} を起動できませんでした: ${err}`, "error"); - sessionStateEl.textContent = `${name} 起動失敗`; - sessionStateEl.dataset.kind = "error"; // A launch that failed after the app claimed the seat releases it there; // this keeps the panel in step with that. await refreshSeats(); - revealDiagnostics(); } finally { startEl.disabled = false; } @@ -848,6 +1219,11 @@ function deleteAccount(): void { return; } accounts = accounts.filter((candidate) => candidate.id !== account.id); + // Its terminal goes with it. An account that no longer exists cannot be named + // in the panel, and the row is the only way that pane could be reached. + discardView(views.get(account.id)); + renderTerminalList(); + renderSessionFacts(); renderAccountOptions(accounts[0]?.id ?? ""); showAccount(); saveAccounts(); @@ -872,45 +1248,20 @@ function renderSocket(port: number | null, error?: string): void { socketStateEl.dataset.kind = "ok"; } -function setUpTerminal(): void { - terminal.loadAddon(fitAddon); - terminal.open(terminalEl); - +/** The page's own colours, so a terminal is not a light rectangle in the dark. */ +function terminalTheme(): { background: string; foreground: string } { const style = getComputedStyle(document.documentElement); - terminal.options.theme = { + return { background: style.getPropertyValue("--bg").trim() || "#17171a", foreground: style.getPropertyValue("--fg").trim() || "#e8e8ea", }; - - terminal.onData((data) => { - if (activePtyId === null) return; - void invoke("write_pty", { id: activePtyId, data }).catch((err) => { - status(`セッションへ送れませんでした: ${err}`, "error"); - }); - }); - - // The webview does not deliver a native paste to xterm, so Ctrl+V is bridged - // explicitly. preventDefault stops the input arriving twice. - terminal.attachCustomKeyEventHandler((event) => { - const isPaste = - event.type === "keydown" && - (event.ctrlKey || event.metaKey) && - !event.altKey && - (event.key === "v" || event.key === "V"); - if (!isPaste) return true; - - event.preventDefault(); - void readText().then((text) => { - if (text && activePtyId !== null) void invoke("write_pty", { id: activePtyId, data: text }); - }); - return false; - }); - - new ResizeObserver(() => fitTerminal()).observe(terminalEl); } async function main(): Promise { - setUpTerminal(); + // The pane is one container holding every session's terminal, so the observer + // is on the container and the fit lands on whichever one is showing. + new ResizeObserver(() => fitShown()).observe(terminalEl); + renderSessionFacts(); nameEl.value = localStorage.getItem(NAME_KEY) ?? "human"; fillHues(hueEl, localStorage.getItem(HUE_KEY)); @@ -971,6 +1322,11 @@ async function main(): Promise { const option = accountEl.selectedOptions[0]; if (option) option.textContent = account.name || "(名前未設定)"; renderPanel(); + // The terminal list is drawn from the account list, so a rename follows the + // pane it belongs to: the row is keyed on the id, and the name is only what + // it is called (#53). + renderTerminalList(); + renderSessionFacts(); }); accountNameEl.addEventListener("change", () => saveAccounts()); accountHueEl.addEventListener("change", () => { @@ -979,6 +1335,7 @@ async function main(): Promise { account.hue = declaredHue(accountHueEl); saveAccounts(); renderPanel(); + renderTerminalList(); }); cwdEl.addEventListener("change", () => { const account = selectedAccount(); diff --git a/src/styles.css b/src/styles.css index 421f41b..5b8bd8c 100644 --- a/src/styles.css +++ b/src/styles.css @@ -352,6 +352,88 @@ body { color: var(--muted); } +/* ── panel: terminals ────────────────────────────────────────────────────── */ + +/* The sessions this screen holds a pane for. Its own block under the roster, + because it answers a different question: the roster says who is in the room, + this says which terminals are here to be watched and ended. */ +#participants .terminals-section { + border-top: 1px solid var(--line); + margin-top: 0.35rem; +} + +#participants .terminals { + margin: 0; + padding: 0 0.6rem 0.35rem; + list-style: none; +} + +#participants .term-row { + display: flex; + align-items: center; + gap: 0.3rem; +} + +/* The name is the control that picks the pane, so it takes the width of the + row: choosing which session to watch is a click on the session, not on a + separate button beside it. */ +#participants .term-row .pick { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 0.5rem; + font: inherit; + text-align: left; + color: var(--fg); + background: none; + border: 1px solid transparent; + border-radius: 4px; + padding: 0.3rem; + cursor: pointer; +} + +/* The pane on the glass. Lifted out of the panel's surface rather than tinted, + so which terminal is showing reads at a glance and in either theme. */ +#participants .term-row.shown .pick { + background: var(--bg); + border-color: var(--line); +} + +/* Dimmed, and the dot goes from filled to outline — the same pair the roster + uses for an account that is not in the room. A session that has ended is not + in it either; the row is still here only for what it printed. */ +#participants .term-row.ended .who { + color: var(--muted); +} + +#participants .term-row.ended .dot { + background: none; + border: 1px solid var(--speaker, var(--line)); +} + +/* Its resting state is a quiet outline in the danger colour: this is the one + control here that cannot be taken back, and it sits next to one that only + changes what is displayed. Armed, it fills — the button says which of its two + states it is in, and the second click is the one that acts (src/main.ts). */ +#participants .term-row .end { + flex: none; + font: inherit; + font-size: 0.72rem; + color: var(--danger); + background: none; + border: 1px solid var(--line); + border-radius: 4px; + padding: 0.2rem 0.4rem; + cursor: pointer; +} + +#participants .term-row .end.armed { + color: var(--bg); + background: var(--danger); + border-color: var(--danger); +} + #participants .facts { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -475,6 +557,13 @@ body { background: var(--bg); } +/* One frame, one terminal per running account, stacked and all but one hidden. + Each fills the frame, so switching between them does not resize anything and + a session is not told its window changed because another was looked at. */ +#terminal .term { + height: 100%; +} + /* xterm sizes itself to the container, so the container must have a size of its own rather than growing with the content. */ #terminal .xterm,