diff --git a/README.md b/README.md index e51d1c9..1905411 100644 --- a/README.md +++ b/README.md @@ -58,7 +58,9 @@ npm run tauri dev 作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。この値はタブ設定として保存されます。 -「診断」を開くと、部屋ソケットの待受状態、セッションの生死、そして**起動した CLI の端末**が確認できます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。 +右の参加者パネルに、部屋にいる参加者と、セッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。発言の色は参加者ごとに違い、名簿の点と同じ色です。自分の色は accent(青)で、名簿では自分の行に「(あなた)」が付きます。 + +「診断」を開くと**起動した CLI の端末**が出ます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。 この端末は表示と操作のためのものです。ここに映る内容が部屋の発言になることはありません。部屋に並ぶのは channel 経由の発言だけです。 diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 9f8ade6..654c029 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -195,8 +195,9 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 部屋の作法(`instructions`)の初版 - 発言の宛先(部屋 → `post.to` → channel の `meta.to`、送信は `say_to_room` の `to`)と、それを判定材料として名指しする `instructions` - 参加者モデル(統一 `post` フレーム、発言者以外の全参加者への配送、接続同一性による自分の発言の抑止、人間を含む名簿) -- チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者表示、自分以外の参加者から選ぶ宛先) -- 診断面(部屋ソケットの待受状態、セッションの生死と終了コード、CLI の端末表示と入力) +- チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者から選ぶ宛先) +- 参加者パネル(名簿、参加者ごとの色、部屋ソケット、セッションの生死と終了コード、接続方法・起動コマンド・作業ディレクトリ・開始時刻・ウィンドウ) +- 診断面(CLI の端末表示と入力) - Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD ### 実機で確認済み(2026-08-21) @@ -212,9 +213,30 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) +### 参加者パネル + +会話面の右に常時出る列を持つ。名簿と、その下にセッションの裏の値(部屋ソケット / セッションの生死と終了コード / 接続方法 / 起動コマンド / 作業ディレクトリ / 開始時刻 / ウィンドウ)を並べる。 + +**読む値と、見る値を分ける。** ここに出るのは、詰まったときに読みに行く値である。折りたたみの下に置くと、開く操作を思いつくかどうかが読めるかどうかになる。CLI の端末だけは別で、あれは読む値ではなく映しているものであるから診断面に残る。 + +名簿の出所は部屋の名簿(`room-participants`)だけである。画面側に第二の一覧を持たない。持てば、宛先に選べる名前と画面に並ぶ名前が食い違う余地ができる。 + +**参加者ごとの色。** 発言の縁の色と名簿の点の色は同じであり、参加者ごとに異なる。明度と彩度は `--accent` の実測値に固定し、色相だけを振る。 + +| | 明度 | 彩度 | +|---|---|---| +| ライト(`#3a6ea5`) | 0.528 | 0.104 | +| ダーク(`#7aa7d8`) | 0.715 | 0.087 | + +色相は参加者名から決まる。到着順ではない。順で決めると、再接続した参加者が別の色で戻り、色が「誰か」ではなく「いつ入ったか」を表すことになる。 + +自分は色相を振らず `--accent` そのものを使い、名前から derive される色相は accent の周囲 50 度を空ける。以前の色分けは自分 / 他人の二値であり(#39)、自分以外の全員が同じ色になっていた。参加者ごとの色相へ移したあとも自分の発言が自分のものと分かるのは、この 1 色が予約されているためである。名簿の自分の行には「(あなた)」が付く。 + +端末はこのパネルへ畳まない。設計キャンバスは右下へ同居させているが、パネルの幅では CLI が枠を描けない(下記「診断面」)。 + ### 診断面 -会話面とは別枠として、部屋ソケットの待受状態・セッションの生死・CLI の出力を表示する診断面を持つ。既定は折りたたみで、セッションの起動失敗や異常終了を検出したときは自動で開く。 +会話面ともパネルとも別枠として、CLI の出力を表示する診断面を持つ。既定は折りたたみで、セッションの起動失敗や異常終了を検出したときは自動で開く。 診断面は起動した CLI をそのまま端末として表示する。CLI はフルスクリーンの TUI であり、カーソル移動・画面消去・部分再描画を前提に出力する。行指向の表示器で映すと再描画の断片が積み上がるだけになるため、端末エミュレータ(xterm.js)で描く。 @@ -225,7 +247,9 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 部屋に並ぶ発言は channel notification と `say_to_room` からのみ来る。端末は表示器であって情報源ではない。会話面(`#room`)に端末を置かないことは維持する。 -診断面は「部屋の下の機構が生きているか」という、会話面が答えられない問いに答える。部屋が沈黙しているとき、その沈黙が正常な待機なのか機構の停止なのかは、会話面だけからは区別できない。 +診断面と参加者パネルは併せて「部屋の下の機構が生きているか」という、会話面が答えられない問いに答える。部屋が沈黙しているとき、その沈黙が正常な待機なのか機構の停止なのかは、会話面だけからは区別できない。パネルが待受と生死を数字で出し、その理由は端末に残る。 + +端末をパネルへ畳まないのはこのためである。CLI が枠を描くには相応の桁数と行数が要り、名簿と同じ幅の列では TUI の再描画が成立しない。設計キャンバスは右下への同居を描いているが、あれは配置の絵であって寸法の指定ではない。 端末はセッションへの入力路でもある。キー入力はそのまま PTY へ渡り、ウィンドウのリサイズは CLI 側へ伝わる。 @@ -273,9 +297,9 @@ CI が実行するもの: 上流から順に確認する。各段は下流の前提であるため、順序を飛ばさない。 -1. **部屋ソケットが待ち受けているか。** タイトルバーの「診断」を開き、部屋ソケットの行を見る。`127.0.0.1: で待受中` でなければ以降はすべて不成立。 -2. **セッションが生きているか。** 同じ診断のセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。直下の端末に理由が残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。 -3. **サイドカーが接続したか。** 参加者表示に**セッション名**が出る。自分(人間)は発言前から名簿に載っているため、参加者表示が空でないことは接続の根拠にならない。セッション名が出ない場合は端末に `room socket: connected as ""` があるかを見る。 +1. **部屋ソケットが待ち受けているか。** 右の参加者パネルの部屋ソケットの行を見る。`127.0.0.1:` が accent 色で出ていなければ以降はすべて不成立。 +2. **セッションが生きているか。** 同じパネルのセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。理由はタイトルバーの「診断」を開いた先の端末に残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。 +3. **サイドカーが接続したか。** パネルの名簿に**セッション名**が出る。自分(人間)は発言前から名簿に載っているため、名簿が空でないことは接続の根拠にならない。セッション名が出ない場合は端末に `room socket: connected as ""` があるかを見る。 4. **サイドカー単体が壊れていないか。** `npm run sidecar:test` を実行する。偽の部屋ソケットを立てて両面を駆動するため、通ればサイドカーではなくアプリ側の問題に絞れる。 5. **`.mcp.json` の登録が効いているか。** セッションの作業ディレクトリの `.mcp.json` に `liplus-chat-room` が入っているかを見る。`--mcp-config` によるファイル渡しでは channel 側が名前を解決できないため、正式登録以外の経路は成立しない。 6. **起動フラグが単独指定になっているか。** `--dangerously-load-development-channels server:liplus-chat-room` 以外に `--channels` が付いていると、同一サーバが二重登録され全体が不通になる。タブ設定に `--channels` / `--print` / `--input-format` / `--output-format` がある場合、セッション起動はエラーで止まる(黙って外すと動いたように見えるため)。 @@ -287,7 +311,7 @@ CI が実行するもの: 2 枚のアートボードを並べ、その差分をロードマップとして読む形にしてある。 -- **次の一手** — 現在の実装に参加者パネルと宛先を足した、実装可能な範囲 +- **次の一手** — 現在の実装に参加者パネルと宛先を足した、実装可能な範囲。両方とも実装済みになったため、この 1 枚と実装の差は操作ボタン(端末 / リサイズ / 終了)と宛先の見た目(削除可能なチップ)だけである。宛先は ` @@ -41,44 +44,77 @@ -
+
+
+
- -
-
+ + +
diff --git a/src-tauri/src/room.rs b/src-tauri/src/room.rs index 90fdf0a..e5e706a 100644 --- a/src-tauri/src/room.rs +++ b/src-tauri/src/room.rs @@ -183,7 +183,12 @@ fn normalize_to(to: Option) -> Option { .filter(|name| !name.is_empty()) } -fn now_iso() -> String { +/// The room's clock. +/// +/// One clock for everything the screen puts a time on. A session's start time +/// is read against the posts around it, so a second implementation elsewhere +/// would be a second clock to keep in step. +pub fn now_iso() -> String { // Tauri already pulls chrono-free time handling in; a plain RFC3339-ish // stamp from SystemTime keeps the dependency list unchanged. let now = std::time::SystemTime::now() diff --git a/src-tauri/src/session.rs b/src-tauri/src/session.rs index d126cf2..f608edb 100644 --- a/src-tauri/src/session.rs +++ b/src-tauri/src/session.rs @@ -94,6 +94,14 @@ pub struct StartedSession { pub pty_id: String, /// Absolute path of the `.mcp.json` this touched, so the UI can say where. pub mcp_config: String, + /// When the PTY was spawned, RFC 3339. + /// + /// Stamped here rather than on the screen because this is the moment the + /// session began: the screen learns of it after the launch has returned, + /// and a launch that takes a while would be recorded as having started + /// late. Same clock as a post's `ts`, so the panel's start time and the + /// first line of the conversation can be read against each other. + pub started_at: String, } #[tauri::command] @@ -146,6 +154,7 @@ pub fn start_session( }, )?; + let started_at = crate::room::now_iso(); let pty_id = pty::spawn_pty( app, pty_state, @@ -159,5 +168,6 @@ pub fn start_session( Ok(StartedSession { pty_id, mcp_config: mcp_config.to_string_lossy().to_string(), + started_at, }) } diff --git a/src/main.ts b/src/main.ts index 2955083..efff01d 100644 --- a/src/main.ts +++ b/src/main.ts @@ -44,10 +44,23 @@ interface AppConfig { interface StartedSession { pty_id: string; mcp_config: string; + /** When the session was launched, stamped by the room's own clock. */ + started_at: string; } const NAME_KEY = "liplus-chat.display-name"; +/** + * Hue of `--accent`, and the arc the other participants are drawn from. + * + * `#3a6ea5` measured in oklch. The accent is this screen's own colour, so the + * derived hues start a gap past it and stop a gap short of it: a participant + * whose name happened to land on the accent would look like oneself. + */ +const ACCENT_HUE = 251.5; +const RESERVED_ARC = 25; +const DERIVED_ARC = 360 - RESERVED_ARC * 2; + const roomEl = document.getElementById("room") as HTMLElement; const rosterEl = document.getElementById("roster") as HTMLElement; const nameEl = document.getElementById("display-name") as HTMLInputElement; @@ -61,6 +74,11 @@ const diagnosticsEl = document.getElementById("diagnostics") as HTMLElement; const toggleEl = document.getElementById("toggle-diagnostics") as HTMLButtonElement; const socketStateEl = document.getElementById("socket-state") as HTMLElement; const sessionStateEl = document.getElementById("session-state") as HTMLElement; +const transportEl = document.getElementById("session-transport") as HTMLElement; +const commandEl = document.getElementById("session-command") as HTMLElement; +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 cwdEl = document.getElementById("session-cwd") as HTMLInputElement; const optionsEl = document.getElementById("launch-options") as HTMLInputElement; @@ -105,6 +123,7 @@ function fitTerminal(): void { return; } if (activePtyId !== null) { + showWindowSize(); void invoke("resize_pty", { id: activePtyId, cols: terminal.cols, @@ -115,6 +134,16 @@ function fitTerminal(): void { } } +/** + * The size the CLI is laid out for. + * + * A TUI that is drawing at the wrong size looks like a broken TUI, and the + * 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}`; +} + /** Render saved arguments back into an editable line. */ function joinArgs(args: string[]): string { return args.map((arg) => (arg === "" || arg.includes(" ") ? `"${arg}"` : arg)).join(" "); @@ -142,6 +171,37 @@ async function refreshPreview(): Promise { } } +/** + * The hue a participant's name lands on. + * + * From the name, so the same participant is the same colour every time they + * speak and in the roster beside their name. Not from arrival order: a + * participant who reconnects would come back a different colour, and the + * colour would then say when they joined rather than who they are. + */ +function hueFor(name: string): number { + // FNV-1a. Any stable spread would do; this one is four lines. + let hash = 2166136261; + for (let i = 0; i < name.length; i += 1) { + hash ^= name.charCodeAt(i); + hash = Math.imul(hash, 16777619); + } + return (ACCENT_HUE + RESERVED_ARC + (Math.abs(hash) % DERIVED_ARC)) % 360; +} + +/** + * The colour a participant is drawn in. + * + * Lightness and chroma are the accent's, in whichever theme is showing; only + * the hue turns. Oneself is the accent itself rather than a hue derived from + * one's own name, which is what keeps one colour in the room recognisably + * one's own now that colour no longer runs on the self/other axis. + */ +function speakerColor(name: string, own: boolean): string { + if (own) return "var(--accent)"; + return `oklch(var(--speaker-l) var(--speaker-c) ${hueFor(name).toFixed(1)})`; +} + function shortTime(iso: string): string { const at = new Date(iso); if (Number.isNaN(at.getTime())) return ""; @@ -155,7 +215,9 @@ function appendMessage(message: RoomMessage): void { const line = document.createElement("article"); line.className = "message"; - line.dataset.own = message.own ? "true" : "false"; + // `own` rather than a name test: the room decides self on the connection a + // post arrived on, which a rename cannot blur (#40). + line.style.setProperty("--speaker", speakerColor(message.speaker, message.own)); const head = document.createElement("div"); head.className = "meta"; @@ -188,9 +250,50 @@ function appendMessage(message: RoomMessage): void { if (atBottom) roomEl.scrollTop = roomEl.scrollHeight; } +/** + * Draw the roster into the panel. + * + * The list is the room's roster and nothing else: the screen keeps no second + * list of who is present, so a name on this panel is a name a post can be + * addressed to. Each entry carries the colour that participant's lines carry + * in the room, which is what makes the panel a legend for the conversation + * rather than a second copy of the same names. + */ function renderRoster(joined: string[]): void { participants = joined; - rosterEl.textContent = joined.length ? joined.join(" / ") : "参加者なし"; + const mine = localName(); + rosterEl.replaceChildren(); + + if (!joined.length) { + const empty = document.createElement("li"); + empty.className = "empty"; + empty.textContent = "参加者なし"; + rosterEl.appendChild(empty); + } + + for (const name of joined) { + const own = name === mine; + const entry = document.createElement("li"); + entry.style.setProperty("--speaker", speakerColor(name, own)); + + const dot = document.createElement("span"); + dot.className = "dot"; + + const who = document.createElement("span"); + who.className = "who"; + who.textContent = name; + who.title = name; + + entry.append(dot, who); + if (own) { + const you = document.createElement("span"); + you.className = "self"; + you.textContent = "(あなた)"; + entry.appendChild(you); + } + rosterEl.appendChild(entry); + } + renderAddressees(); } @@ -279,6 +382,17 @@ async function followSession(tab: TabConfig, started: StartedSession): Promise(`pty-data-${started.pty_id}`, (event) => terminal.write(event.payload)); await listen(`pty-exit-${started.pty_id}`, (event) => { const code = event.payload; @@ -358,7 +472,9 @@ function renderSocket(port: number | null, error?: string): void { socketStateEl.dataset.kind = "error"; return; } - socketStateEl.textContent = `127.0.0.1:${port} で待受中`; + // The address alone. It is an address, and the panel column is one line + // wide; that it is being listened on is what the accent colour says. + socketStateEl.textContent = `127.0.0.1:${port}`; socketStateEl.dataset.kind = "ok"; } diff --git a/src/styles.css b/src/styles.css index 8ff8ff1..be204af 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7,6 +7,12 @@ --surface: #f4f4f7; --accent: #3a6ea5; --danger: #a52a2a; + /* One participant is told from another by hue alone: every participant + colour is the accent's own lightness and chroma with the hue turned. The + pair below is #3a6ea5 measured in oklch, not a value chosen next to it, so + a participant sits at the same weight on the page as the accent does. */ + --speaker-l: 0.528; + --speaker-c: 0.104; } @media (prefers-color-scheme: dark) { @@ -18,6 +24,11 @@ --surface: #202026; --accent: #7aa7d8; --danger: #e08585; + /* #7aa7d8 measured the same way. The dark accent is lighter and slightly + less saturated, so carrying the light pair over here would leave every + participant but oneself too dark to read against #17171a. */ + --speaker-l: 0.715; + --speaker-c: 0.087; } } @@ -58,14 +69,9 @@ body { font-weight: 600; } -#titlebar .roster { - color: var(--muted); - /* The roster is the one thing here that grows; it takes the slack so the - controls stay pinned to the right. */ +/* Takes the slack so the controls stay pinned to the right. */ +#titlebar .spacer { flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; } #titlebar .who { @@ -99,6 +105,25 @@ body { opacity: 0.6; } +/* ── body ────────────────────────────────────────────────────────────────── */ + +/* The conversation and the panel stand side by side, so the panel is read + without leaving the room. */ +#body { + flex: 1; + display: flex; + min-height: 0; +} + +#conversation { + flex: 1; + display: flex; + flex-direction: column; + /* Without this a long unbroken line in the room widens the column and pushes + the panel off the window. */ + min-width: 0; +} + /* ── room ────────────────────────────────────────────────────────────────── */ #room { @@ -110,20 +135,21 @@ body { gap: 0.75rem; } +/* Speaker attribution is the load-bearing distinction in this room, so one + participant is separated from another by colour as well as by name. The axis + is the participant: `--speaker` is set per line from the name on it, so the + colour beside a line and the dot beside that name in the panel are the same + colour, and a line can be traced to a participant without reading it. + Self/other was the earlier axis (#39) and is a two-valued one — it gave every + participant but oneself one shared colour. Oneself keeps the accent, and the + derived hues leave a gap around it, so that one colour still reads as one's + own. */ .message { max-width: 46rem; - border-left: 3px solid var(--line); + border-left: 3px solid var(--speaker, var(--line)); padding-left: 0.7rem; } -/* Speaker attribution is the load-bearing distinction in this room, so one - participant is separated from another by colour as well as by name. The - axis is self vs other: the room carries no human/AI distinction, and a - colour drawn on one would be reading a line that is no longer there (#39). */ -.message[data-own="true"] { - border-left-color: var(--accent); -} - .message .meta { display: flex; align-items: baseline; @@ -137,8 +163,10 @@ body { color: var(--fg); } +/* The addressee is written in the speaker's colour, not the addressee's: it is + part of what this speaker said. */ .message .to { - color: var(--accent); + color: var(--speaker, var(--accent)); } .message .body { @@ -226,33 +254,122 @@ body { padding: 0.6rem 0.9rem; font-size: 0.78rem; /* The CLI is a full-screen TUI; a strip too short to hold its frame is not - worth opening. */ + worth opening. This is also why the terminal stayed in this column instead + of folding into the participant panel: at the panel's width the CLI has no + frame to draw. */ height: 50vh; display: flex; flex-direction: column; gap: 0.5rem; } -#diagnostics .facts { - display: grid; - grid-template-columns: auto 1fr; - gap: 0.2rem 0.75rem; +/* The `display` above outranks the user agent's rule for the attribute, so + without this the pane is never actually folded and the terminal sits under + the room from the first frame. */ +#diagnostics[hidden] { + display: none; +} + +/* ── participants ────────────────────────────────────────────────────────── */ + +#participants { + width: 16.5rem; + flex: none; + border-left: 1px solid var(--line); + background: var(--surface); + display: flex; + flex-direction: column; + min-height: 0; + overflow-y: auto; + font-size: 0.78rem; +} + +#participants .panel-title { margin: 0; + padding: 0.6rem 0.9rem 0.35rem; + font-size: inherit; + font-weight: 600; } -#diagnostics dt { +#participants .roster { + margin: 0; + padding: 0; + list-style: none; +} + +#participants .roster li { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.45rem 0.9rem; +} + +/* The same colour the participant's lines carry in the room. */ +#participants .dot { + width: 0.45rem; + height: 0.45rem; + flex: none; + border-radius: 999px; + background: var(--speaker, var(--line)); +} + +#participants .who { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* The name this screen posts under. Colour alone would say it, but only to + someone who already knows which colour is theirs. */ +#participants .self { + color: var(--muted); +} + +#participants .empty { + padding: 0.45rem 0.9rem; + color: var(--muted); +} + +#participants .facts { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 0.3rem 0.6rem; + margin: 0.35rem 0 0; + border-top: 1px solid var(--line); + padding: 0.6rem 0.9rem; + font-size: 0.75rem; +} + +#participants dt { color: var(--muted); } -#diagnostics dd { +/* Values are read against each other down the right edge, and every one of + them is a literal — a port, a path, a command — so they are set in the same + face the terminal uses. */ +#participants dd { margin: 0; + font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace; + text-align: right; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* A path is the one value here that is longer than the column. It gets the + whole width, below its own label, because a working directory truncated in + the middle is the value least worth guessing at. */ +#participants dd.path { + grid-column: 1 / -1; + text-align: left; } -#diagnostics dd[data-kind="ok"] { +#participants dd[data-kind="ok"] { color: var(--accent); } -#diagnostics dd[data-kind="error"] { +#participants dd[data-kind="error"] { color: var(--danger); }