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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ dist/
src-tauri/target/
crates/*/target/

# Written by a session launched from this directory (see src-tauri/src/session.rs)
.mcp.json

# Environment
.env
.env.*
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,9 @@ npm run tauri dev

起動すると Tauri の窓が開き、部屋ソケットが待ち受けを始めます。フロントエンドの dev サーバは `vite.config.ts` で 1420 番に固定しています(`src-tauri/tauri.conf.json` の `devUrl` と一致させる必要があるため)。

タイトルバーの「診断」で、部屋ソケットの待受状態、セッションの生死、CLI の出力が確認できます。起動に失敗したときは自動で開きます。
タブの下に作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。この値はタブ設定として保存されます。

「診断」を開くと、部屋ソケットの待受状態、セッションの生死、CLI の出力が確認できます。ここにはセッションへの入力欄もあります。CLI はフォルダごとに初回の信頼確認を出すため、**最初の一回はここから答える必要があります**。Enter / ↑ / ↓ / Esc / Ctrl+C のボタンも用意しています。

タイトルバーでセッションを選び「セッション参加」を押すと、次の 2 つが行われます。

Expand Down
15 changes: 14 additions & 1 deletion docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre
- サーバの `.mcp.json` 登録(既存内容はマージして保持)と、成立条件を満たす CLI 起動フラグの適用
- 部屋の作法(`instructions`)の初版
- チャットルーム UI(メッセージ一覧、発言者表示、入力欄、参加者表示)
- 診断面(部屋ソケットの待受状態、セッションの生死と終了コード、CLI 出力、セッションへの入力路)
- Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD

### 未検証
Expand All @@ -164,6 +165,18 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre

これは「ターミナルエミュレータを置かない」制約と衝突しない。会話面に CLI の生出力を流さないという制約は、何が発言かという axis の話であり、診断面は「部屋の下の機構が生きているか」という別の問いに答える。部屋が沈黙しているとき、その沈黙が正常な待機なのか機構の停止なのかは、会話面だけからは区別できない。

診断面はセッションへの入力路も持つ。文字入力に加えて Enter / ↑ / ↓ / Esc / Ctrl+C を送れる。

これも会話面とは別の axis にある。CLI はフォルダごとに初回の信頼確認を出すため、この経路が無いとどの作業ディレクトリでもセッションは起動せず、部屋が立ち上がること自体が構造的に不可能になる。

信頼確認や権限確認をアプリが自動で答えることはしない。それらはセキュリティ上の確認であり、判断は人間が行う。アプリが負うのは、人間が答えられる経路を用意することであって、代わりに答えることではない。

### 作業ディレクトリ

セッションの作業ディレクトリは画面に表示し、変更でき、タブ設定として保存する。未設定のままアプリのプロセス cwd へ暗黙にフォールバックしない。

`npm run tauri dev` ではアプリのプロセス cwd が `src-tauri/` になるため、暗黙のフォールバックはセッションを `src-tauri/` で起動させ、`.mcp.json` もそこへ書く。作業ディレクトリはユーザーの選択であって、アプリのプロセスがたまたま居た場所ではない。初回はホームディレクトリを prefill するが、これは既定値ではなく prefill である。画面に出ていないディレクトリでセッションを起動しない。

## テストの配置

`.mcp.json` への登録と起動フラグの検査は、`crates/mcp-config/` という tauri 非依存の crate に置く。
Expand All @@ -187,7 +200,7 @@ CI が実行するもの:
上流から順に確認する。各段は下流の前提であるため、順序を飛ばさない。

1. **部屋ソケットが待ち受けているか。** タイトルバーの「診断」を開き、部屋ソケットの行を見る。`127.0.0.1:<port> で待受中` でなければ以降はすべて不成立。
2. **セッションが生きているか。** 同じ診断のセッション行を見る。`終了(終了コード N)` なら CLI が落ちている。直下のセッション出力に理由が残る。
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 側が名前を解決できないため、正式登録以外の経路は成立しない。
Expand Down
31 changes: 27 additions & 4 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,47 @@
名前
<input id="display-name" type="text" value="human" spellcheck="false" />
</label>
<select id="tab-select" aria-label="起動するセッション"></select>
<button id="start-session" type="button">セッション参加</button>
<button id="toggle-diagnostics" type="button" aria-expanded="false">診断</button>
</header>

<div id="launcher">
<select id="tab-select" aria-label="起動するセッション"></select>
<label class="cwd">
作業ディレクトリ
<input id="session-cwd" type="text" spellcheck="false" />
</label>
<button id="start-session" type="button">セッション参加</button>
</div>

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

<!-- Diagnostics, not the conversation surface. The room shows messages;
this shows whether the machinery under it is alive. Collapsed by
default so it does not compete with the room. -->
this shows whether the machinery under it is alive, and carries the
keyboard the CLI needs before it will start at all. -->
<aside id="diagnostics" hidden>
<dl class="facts">
<dt>部屋ソケット</dt>
<dd id="socket-state">確認中</dd>
<dt>セッション</dt>
<dd id="session-state">未起動</dd>
</dl>

<pre id="session-log" aria-label="セッションの出力"></pre>

<div id="session-keys">
<input
id="session-input"
type="text"
spellcheck="false"
placeholder="セッションへ入力(Enter で送信)"
disabled
/>
<button class="key" type="button" data-key="enter" disabled>Enter</button>
<button class="key" type="button" data-key="up" disabled>↑</button>
<button class="key" type="button" data-key="down" disabled>↓</button>
<button class="key" type="button" data-key="escape" disabled>Esc</button>
<button class="key" type="button" data-key="interrupt" disabled>Ctrl+C</button>
</div>
</aside>

<footer id="composer">
Expand Down
12 changes: 12 additions & 0 deletions src-tauri/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,18 @@ impl Default for AppConfig {
}
}

/// The user's home directory, used to prefill a session's working directory.
///
/// A prefill, not a default: the app never launches a session anywhere the
/// person has not seen on screen.
#[tauri::command]
pub fn home_dir(app: AppHandle) -> Result<String, String> {
app.path()
.home_dir()
.map(|dir| dir.to_string_lossy().to_string())
.map_err(|e| format!("Failed to resolve home dir: {e}"))
}

fn config_path(app: &AppHandle) -> Result<PathBuf, String> {
let dir = app
.path()
Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ pub fn run() {
pty::write_pty,
pty::resize_pty,
pty::kill_pty,
config::home_dir,
config::load_config,
config::save_config,
config::save_sessions,
Expand Down
15 changes: 11 additions & 4 deletions src-tauri/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -73,10 +73,17 @@ pub fn start_session(
.ok_or_else(|| "The room socket is not listening yet.".to_string())?;
let room_url = format!("ws://127.0.0.1:{port}");

let cwd = match tab.cwd.as_deref() {
Some(dir) => PathBuf::from(dir),
None => std::env::current_dir()
.map_err(|e| format!("Failed to resolve the working directory: {e}"))?,
// No fallback to the app's own process directory. Under `tauri dev` that
// is `src-tauri`, and a session silently launched there is a session the
// person never chose and cannot see they got (#20).
let cwd = match tab.cwd.as_deref().map(str::trim) {
Some(dir) if !dir.is_empty() => PathBuf::from(dir),
_ => {
return Err(format!(
"Tab \"{}\" has no working directory. Set one before starting a session.",
tab.name
))
}
};
if !cwd.is_dir() {
return Err(format!("Tab \"{}\" points at a missing directory: {}", tab.name, cwd.display()));
Expand Down
98 changes: 97 additions & 1 deletion src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,30 @@ const toggleEl = document.getElementById("toggle-diagnostics") as HTMLButtonElem
const socketStateEl = document.getElementById("socket-state") as HTMLElement;
const sessionStateEl = document.getElementById("session-state") as HTMLElement;
const logEl = document.getElementById("session-log") as HTMLPreElement;
const cwdEl = document.getElementById("session-cwd") as HTMLInputElement;
const sessionInputEl = document.getElementById("session-input") as HTMLInputElement;
const keyEls = Array.from(
document.querySelectorAll<HTMLButtonElement>("#session-keys .key"),
);

let tabs: TabConfig[] = [];
let logLines: string[] = [];
/** The session the diagnostics keyboard is attached to, once one is running. */
let activePtyId: string | null = null;

// Control bytes built from character codes rather than written as escapes: the
// same reason the strip patterns below say so. A CLI prompt is navigated with
// these, and the folder-trust prompt is the first thing every session shows.
const CTRL_C = String.fromCharCode(3);
const CARRIAGE_RETURN = String.fromCharCode(13);
const ESCAPE = String.fromCharCode(27);
const KEYS: Record<string, string> = {
enter: CARRIAGE_RETURN,
up: ESCAPE + "[A",
down: ESCAPE + "[B",
escape: ESCAPE,
interrupt: CTRL_C,
};

function status(text: string, kind: "info" | "error" = "info"): void {
statusEl.textContent = text;
Expand All @@ -72,6 +93,28 @@ function revealDiagnostics(): void {
toggleEl.setAttribute("aria-expanded", "true");
}

function setKeyboardEnabled(enabled: boolean): void {
sessionInputEl.disabled = !enabled;
for (const key of keyEls) key.disabled = !enabled;
}

/**
* Send raw bytes to the running session.
*
* The CLI asks before it will work in a directory, and that question is a
* security question: the app must not answer it. What the app owes is a way
* for the person to answer it themselves — without this the session stops at
* the first prompt and the room can never come up.
*/
async function sendToSession(data: string): Promise<void> {
if (activePtyId === null) return;
try {
await invoke("write_pty", { id: activePtyId, data });
} catch (err) {
status(`セッションへ送れませんでした: ${err}`, "error");
}
}

// Terminal control sequences, built from escapes rather than written as
// literal control bytes: a raw 0x1b in the source survives an editor round
// trip only by luck. This pane is read, not driven, so the sequences are
Expand Down Expand Up @@ -170,6 +213,8 @@ async function send(): Promise<void> {
async function followSession(tab: TabConfig, started: StartedSession): Promise<void> {
sessionStateEl.textContent = `${tab.name} 起動中`;
sessionStateEl.dataset.kind = "ok";
activePtyId = started.pty_id;
setKeyboardEnabled(true);

await listen<string>(`pty-data-${started.pty_id}`, (event) => appendLog(event.payload));
await listen<number | null>(`pty-exit-${started.pty_id}`, (event) => {
Expand All @@ -178,8 +223,14 @@ async function followSession(tab: TabConfig, started: StartedSession): Promise<v
sessionStateEl.textContent = `${tab.name} 終了(${detail})`;
sessionStateEl.dataset.kind = "error";
status(`${tab.name} が終了しました(${detail})。診断を確認してください。`, "error");
activePtyId = null;
setKeyboardEnabled(false);
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();
}

async function startSession(): Promise<void> {
Expand All @@ -189,14 +240,31 @@ async function startSession(): Promise<void> {
return;
}

const cwd = cwdEl.value.trim();
if (!cwd) {
status("作業ディレクトリを入力してください。", "error");
cwdEl.focus();
return;
}
// The directory is the person's choice, so it is carried on the tab and
// saved. Falling back to whatever directory the app process happens to sit
// in is what put a session in src-tauri (#20).
const launching: TabConfig = { ...tab, cwd };

startEl.disabled = true;
status(`${tab.name} を起動しています…`);
try {
const started = await invoke<StartedSession>("start_session", {
tab,
tab: launching,
cols: 120,
rows: 30,
});
tab.cwd = cwd;
void invoke("save_config", { config: { tabs } }).catch(() => {
// A directory that fails to persist is worth one line, not a failed
// launch: the session is already up.
status("作業ディレクトリを保存できませんでした。", "error");
});
status(`${tab.name} を起動しました。${started.mcp_config} に登録済み。`);
await followSession(tab, started);
} catch (err) {
Expand Down Expand Up @@ -251,6 +319,32 @@ async function main(): Promise<void> {
});
startEl.addEventListener("click", () => void startSession());

sessionInputEl.addEventListener("keydown", (event) => {
if (event.key !== "Enter" || event.isComposing) return;
event.preventDefault();
const text = sessionInputEl.value;
sessionInputEl.value = "";
void sendToSession(text + KEYS.enter);
});
for (const key of keyEls) {
key.addEventListener("click", () => {
const sequence = KEYS[key.dataset.key ?? ""];
if (sequence) void sendToSession(sequence);
});
}

let home = "";
try {
home = await invoke<string>("home_dir");
} catch {
// Only the prefill is lost; the field is still typed into by hand.
}

const showCwd = (): void => {
const tab = tabs.find((candidate) => candidate.id === tabEl.value);
cwdEl.value = tab?.cwd ?? home;
};

try {
const config = await invoke<AppConfig>("load_config");
tabs = config.tabs;
Expand All @@ -260,6 +354,8 @@ async function main(): Promise<void> {
option.textContent = tab.name;
tabEl.appendChild(option);
}
showCwd();
tabEl.addEventListener("change", showCwd);
} catch (err) {
status(`設定を読み込めませんでした: ${err}`, "error");
}
Expand Down
Loading
Loading