Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,9 @@ npm run tauri dev

作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。この値はタブ設定として保存されます。

「診断」を開くと、部屋ソケットの待受状態、セッションの生死、そして**起動した CLI の端末**が確認できます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。
右の参加者パネルに、部屋にいる参加者と、セッションの裏の値(部屋ソケット、セッションの生死、接続方法、起動コマンド、作業ディレクトリ、開始時刻、ウィンドウ)が出ます。発言の色は参加者ごとに違い、名簿の点と同じ色です。自分の色は accent(青)で、名簿では自分の行に「(あなた)」が付きます。

「診断」を開くと**起動した CLI の端末**が出ます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。

この端末は表示と操作のためのものです。ここに映る内容が部屋の発言になることはありません。部屋に並ぶのは channel 経由の発言だけです。

Expand Down
43 changes: 34 additions & 9 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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)で描く。

Expand All @@ -225,7 +247,9 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre

部屋に並ぶ発言は channel notification と `say_to_room` からのみ来る。端末は表示器であって情報源ではない。会話面(`#room`)に端末を置かないことは維持する。

診断面は「部屋の下の機構が生きているか」という、会話面が答えられない問いに答える。部屋が沈黙しているとき、その沈黙が正常な待機なのか機構の停止なのかは、会話面だけからは区別できない。
診断面と参加者パネルは併せて「部屋の下の機構が生きているか」という、会話面が答えられない問いに答える。部屋が沈黙しているとき、その沈黙が正常な待機なのか機構の停止なのかは、会話面だけからは区別できない。パネルが待受と生死を数字で出し、その理由は端末に残る。

端末をパネルへ畳まないのはこのためである。CLI が枠を描くには相応の桁数と行数が要り、名簿と同じ幅の列では TUI の再描画が成立しない。設計キャンバスは右下への同居を描いているが、あれは配置の絵であって寸法の指定ではない。

端末はセッションへの入力路でもある。キー入力はそのまま PTY へ渡り、ウィンドウのリサイズは CLI 側へ伝わる。

Expand Down Expand Up @@ -273,9 +297,9 @@ CI が実行するもの:

上流から順に確認する。各段は下流の前提であるため、順序を飛ばさない。

1. **部屋ソケットが待ち受けているか。** タイトルバーの「診断」を開き、部屋ソケットの行を見る。`127.0.0.1:<port> で待受中` でなければ以降はすべて不成立
2. **セッションが生きているか。** 同じ診断のセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。直下の端末に理由が残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。
3. **サイドカーが接続したか。** 参加者表示に**セッション名**が出る。自分(人間)は発言前から名簿に載っているため、参加者表示が空でないことは接続の根拠にならない。セッション名が出ない場合は端末に `room socket: connected as "<name>"` があるかを見る。
1. **部屋ソケットが待ち受けているか。** 右の参加者パネルの部屋ソケットの行を見る。`127.0.0.1:<port>` が accent 色で出ていなければ以降はすべて不成立
2. **セッションが生きているか。** 同じパネルのセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。理由はタイトルバーの「診断」を開いた先の端末に残る。CLI が確認プロンプトで止まっている場合は、端末へ直接答える。
3. **サイドカーが接続したか。** パネルの名簿に**セッション名**が出る。自分(人間)は発言前から名簿に載っているため、名簿が空でないことは接続の根拠にならない。セッション名が出ない場合は端末に `room socket: connected as "<name>"` があるかを見る。
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` がある場合、セッション起動はエラーで止まる(黙って外すと動いたように見えるため)。
Expand All @@ -287,7 +311,7 @@ CI が実行するもの:

2 枚のアートボードを並べ、その差分をロードマップとして読む形にしてある。

- **次の一手** — 現在の実装に参加者パネルと宛先を足した、実装可能な範囲
- **次の一手** — 現在の実装に参加者パネルと宛先を足した、実装可能な範囲。両方とも実装済みになったため、この 1 枚と実装の差は操作ボタン(端末 / リサイズ / 終了)と宛先の見た目(削除可能なチップ)だけである。宛先は `<select>` で機能的に等価であり、形の差は追っていない。
- **全体像** — ルーム一覧 / ダイレクト / 参加者・セッション状態パネル

色と寸法は `src/styles.css` の実値をそのまま使っている(`--accent: #3a6ea5` / `--surface: #f4f4f7` / 角丸 4・6px / system-ui + Yu Gothic UI)。参加者ごとの色は accent と同じ彩度・明度で色相だけ変えたもの。絵と実装が同じトークンの上に乗るため、キャンバスの値をそのまま実装へ移せる。
Expand All @@ -297,8 +321,9 @@ CI が実行するもの:
### 決まったこと

- 会話面はメッセージ単位を保つ。端末は診断面側に置き、会話面には置かない。
- 参加者パネルを持つセッションの接続方法・起動コマンド・作業ディレクトリ・開始時刻と、操作(端末 / ミュート / 終了)を出す。裏の値はすべて既にあり、出すだけで済む
- 参加者パネルを持つ(実装済み。上記「参加者パネル」)。セッションの接続方法・起動コマンド・作業ディレクトリ・開始時刻を出す。操作(端末 / ミュート / 終了)は未実装であり、キャンバス側にのみある
- 発言に宛先を持たせる(実装済み。入力欄の左で参加者から選ぶ。既定は全体宛)。
- 参加者ごとの色(実装済み。accent と同じ明度・彩度で色相のみ)。

### 決まっていないこと

Expand Down
106 changes: 71 additions & 35 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
<div id="app">
<header id="titlebar">
<span class="title">Li+ Chat</span>
<span id="roster" class="roster">参加者なし</span>
<!-- The roster used to be echoed here as one line. It is the
participant panel's list now: two renderings of one roster is one
surface too many, and the panel is always on screen. -->
<span class="spacer"></span>
<label class="who">
名前
<input id="display-name" type="text" value="human" spellcheck="false" />
Expand Down Expand Up @@ -41,44 +44,77 @@
<span id="launch-preview" class="preview"></span>
</div>

<main id="room" aria-live="polite"></main>
<div id="body">
<div id="conversation">
<main id="room" aria-live="polite"></main>

<!-- Diagnostics, not the conversation surface. The room shows what was
said; this shows the CLI that was launched, as it actually renders.
Nothing here is read as a message: the room's lines come from the
channel and from say_to_room, never from this output. -->
<aside id="diagnostics" hidden>
<dl class="facts">
<dt>部屋ソケット</dt>
<dd id="socket-state">確認中</dd>
<dt>セッション</dt>
<dd id="session-state">未起動</dd>
</dl>
<!-- Diagnostics, not the conversation surface. The room shows what
was said; this shows the CLI that was launched, as it actually
renders. Nothing here is read as a message: the room's lines come
from the channel and from say_to_room, never from this output.

<div id="terminal" aria-label="セッションの端末"></div>
</aside>
Only the terminal is left here. The values that are read rather
than watched — the socket, the session, how it was launched —
moved to the participant panel, where they are on screen without
opening anything. -->
<aside id="diagnostics" hidden>
<div id="terminal" aria-label="セッションの端末"></div>
</aside>

<footer id="composer">
<!-- The addressee is optional. Leaving it on 全体 is a room-wide
utterance; who answers one of those is the participants'
judgment, not the room's. The room delivers every utterance to
everyone either way, and anyone in the roster can be named —
people and sessions alike. -->
<label class="to">
宛先
<select id="to-select">
<option value="">全体</option>
</select>
</label>
<textarea
id="input"
rows="2"
placeholder="部屋に発言する(Enter で送信 / Shift+Enter で改行)"
></textarea>
<button id="send" type="button">送信</button>
</footer>
<footer id="composer">
<!-- The addressee is optional. Leaving it on 全体 is a room-wide
utterance; who answers one of those is the participants'
judgment, not the room's. The room delivers every utterance to
everyone either way, and anyone in the roster can be named —
people and sessions alike. -->
<label class="to">
宛先
<select id="to-select">
<option value="">全体</option>
</select>
</label>
<textarea
id="input"
rows="2"
placeholder="部屋に発言する(Enter で送信 / Shift+Enter で改行)"
></textarea>
<button id="send" type="button">送信</button>
</footer>

<div id="status" role="status"></div>
</div>

<div id="status" role="status"></div>
<!-- Who is in the room, and what the session under it was launched
from. The values were all reachable before this panel existed;
what was missing was a place that shows them without being
opened. -->
<aside id="participants" aria-label="参加者">
<h2 class="panel-title">参加者</h2>
<!-- Seeded, because the roster only arrives once the room answers:
a blank strip there would read as "nobody" before anyone has
been asked. -->
<ul id="roster" class="roster">
<li class="empty">参加者なし</li>
</ul>

<dl class="facts">
<dt>部屋ソケット</dt>
<dd id="socket-state">確認中</dd>
<dt>セッション</dt>
<dd id="session-state">未起動</dd>
<dt>接続方法</dt>
<dd id="session-transport">—</dd>
<dt>起動コマンド</dt>
<dd id="session-command">—</dd>
<dt>作業ディレクトリ</dt>
<dd id="session-dir" class="path">—</dd>
<dt>開始時刻</dt>
<dd id="session-started">—</dd>
<dt>ウィンドウ</dt>
<dd id="session-window">—</dd>
</dl>
</aside>
</div>
</div>
</body>
</html>
7 changes: 6 additions & 1 deletion src-tauri/src/room.rs
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,12 @@ fn normalize_to(to: Option<String>) -> Option<String> {
.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()
Expand Down
10 changes: 10 additions & 0 deletions src-tauri/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -146,6 +154,7 @@ pub fn start_session(
},
)?;

let started_at = crate::room::now_iso();
let pty_id = pty::spawn_pty(
app,
pty_state,
Expand All @@ -159,5 +168,6 @@ pub fn start_session(
Ok(StartedSession {
pty_id,
mcp_config: mcp_config.to_string_lossy().to_string(),
started_at,
})
}
Loading