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 @@ -54,7 +54,9 @@ npm run tauri dev

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

タブの下に作業ディレクトリの入力欄があります。初回はホームディレクトリが入っているので、セッションを動かしたいディレクトリへ変更してください。この値はタブ設定として保存されます。
タブの下に「起動オプション」の入力欄があります。`--dangerously-skip-permissions` のように、CLI へ渡したいオプションをそのまま書けます。アプリは部屋の channel エントリ(`server:liplus-chat-room`)をここへ統合するので、別の channel サーバを指定しても部屋の入力路は残ります。実際に起動する行は入力欄の右に表示されます。

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

「診断」を開くと、部屋ソケットの待受状態、セッションの生死、そして**起動した CLI の端末**が確認できます。端末はそのまま操作できます。CLI はフォルダごとに初回の信頼確認を出すため、最初の一回はここから答えてください。

Expand Down
124 changes: 122 additions & 2 deletions crates/mcp-config/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -56,11 +56,70 @@ pub fn reject_incompatible_flags(args: &[String]) -> Result<(), &'static str> {
Ok(())
}

/// The flag that loads channel servers into a session.
pub const CHANNEL_FLAG: &str = "--dangerously-load-development-channels";

/// The launch arguments for a channel-enabled session, given the tab's own.
///
/// The room's entry is merged into whatever the person wrote rather than added
/// as a second flag: `--channels` alongside this one registers a server twice
/// and takes the whole room down, and two copies of this flag is the same
/// shape. Merging also means the room's input path cannot be dropped by
/// configuring a different server — losing it is losing the room.
pub fn channel_launch_args(base: &[String]) -> Vec<String> {
let room = format!("server:{SERVER_NAME}");
let mut args = base.to_vec();
args.push("--dangerously-load-development-channels".to_string());
args.push(format!("server:{SERVER_NAME}"));

if args.iter().any(|arg| *arg == room) {
return args;
}

match args.iter().position(|arg| arg == CHANNEL_FLAG) {
// Right after the flag: the values are positional, and keeping them
// contiguous means a later argument cannot be captured as one.
Some(index) => args.insert(index + 1, room),
None => {
args.push(CHANNEL_FLAG.to_string());
args.push(room);
}
}
args
}

/// Split a launch-options string the way a shell would, minus the parts a
/// shell does that have no place here.
///
/// Double quotes group, because Windows paths have spaces in them and a bare
/// whitespace split turns one such argument into two without saying so.
/// Nothing else is interpreted: no variable expansion, no globbing, no escape
/// characters — a backslash in a Windows path is a backslash.
pub fn split_launch_options(text: &str) -> Vec<String> {
let mut args = Vec::new();
let mut current = String::new();
let mut quoted = false;
let mut has_token = false;

for ch in text.chars() {
match ch {
'"' => {
quoted = !quoted;
has_token = true;
}
c if c.is_whitespace() && !quoted => {
if has_token {
args.push(std::mem::take(&mut current));
has_token = false;
}
}
c => {
current.push(c);
has_token = true;
}
}
}
if has_token {
args.push(current);
}
args
}

Expand Down Expand Up @@ -292,6 +351,67 @@ mod tests {
assert_eq!(reject_incompatible_flags(&args), Ok(()));
}

#[test]
fn merges_the_room_into_a_channel_flag_the_person_already_wrote() {
// Master's own launch line, which names a different channel server.
// A second copy of the flag is the `--channels` failure in another
// shape, and dropping the room entry loses the room's input path.
let base: Vec<String> = [
"--dangerously-skip-permissions",
CHANNEL_FLAG,
"server:github-webhook-mcp",
]
.iter()
.map(|s| s.to_string())
.collect();

let merged = channel_launch_args(&base);
assert_eq!(
merged,
vec![
"--dangerously-skip-permissions".to_string(),
CHANNEL_FLAG.to_string(),
format!("server:{SERVER_NAME}"),
"server:github-webhook-mcp".to_string(),
]
);
assert_eq!(
merged.iter().filter(|arg| *arg == CHANNEL_FLAG).count(),
1,
"the flag must not appear twice"
);
}

#[test]
fn does_not_add_the_room_twice() {
let base = vec![CHANNEL_FLAG.to_string(), format!("server:{SERVER_NAME}")];
assert_eq!(channel_launch_args(&base), base);
}

#[test]
fn splits_launch_options_keeping_quoted_arguments_whole() {
assert_eq!(
split_launch_options(" --a --b=1 "),
vec!["--a".to_string(), "--b=1".to_string()]
);
// A Windows path with spaces is one argument, and its backslashes are
// literal rather than escapes.
assert_eq!(
split_launch_options(r#"--add-dir "C:\Program Files\x" --flag"#),
vec![
"--add-dir".to_string(),
r"C:\Program Files\x".to_string(),
"--flag".to_string(),
]
);
assert!(split_launch_options(" ").is_empty());
// An empty quoted argument is an argument, not nothing.
assert_eq!(
split_launch_options("--x \"\""),
vec!["--x".to_string(), String::new()]
);
}

#[test]
fn the_launch_flag_names_the_server_the_config_registers() {
// The flag and the `.mcp.json` key are one fact in two places; a drift
Expand Down
12 changes: 12 additions & 0 deletions docs/0-requirements.md
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,18 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre

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

### 起動オプション

セッションの起動オプションは画面から編集でき、タブ設定として保存する。

アプリ自身の channel エントリ(`server:liplus-chat-room`)は、利用者が書いたものへ**統合**する。利用者が `--dangerously-load-development-channels` を書いていればその直後へ挿入し、書いていなければフラグごと足す。第二のフラグとしては足さない。同一フラグの二重指定は `--channels` 併記と同じ形であり、成立条件が壊れる。

統合であって上書きではない。利用者が別の channel サーバを指定しても、部屋のエントリは落ちない。落ちれば部屋の入力路そのものが消える。

実際に起動する行は画面に表示する。書いた行と走る行が違う以上、結果を見せるほうが説明より安い。

**未検証**: 一つのフラグに `server:` エントリを複数並べられるかは確認できていない。#90 の実測が確定しているのは `--channels` 併記による二重登録の失敗であり、同一フラグへの複数エントリは扱っていない。`--print` での確認は無意味である(channel の読み込み自体が走らないため)。対話セッションでのみ確かめられる。

### 作業ディレクトリ

セッションの作業ディレクトリは画面に表示し、変更でき、タブ設定として保存する。未設定のままアプリのプロセス cwd へ暗黙にフォールバックしない。
Expand Down
13 changes: 13 additions & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,19 @@
<button id="start-session" type="button">セッション参加</button>
</div>

<div id="launch-row">
<label class="options">
起動オプション
<input
id="launch-options"
type="text"
spellcheck="false"
placeholder="例: --dangerously-skip-permissions"
/>
</label>
<span id="launch-preview" class="preview"></span>
</div>

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

<!-- Diagnostics, not the conversation surface. The room shows what was
Expand Down
2 changes: 2 additions & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ pub fn run() {
room::room_port,
room::room_agents,
room::room_say,
session::parse_launch_options,
session::preview_launch_args,
session::start_session,
])
.run(tauri::generate_context!())
Expand Down
19 changes: 19 additions & 0 deletions src-tauri/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,25 @@ fn resolve_sidecar_paths() -> Result<(PathBuf, PathBuf), String> {
.to_string())
}

/// Split a launch-options string into arguments.
///
/// The splitter lives in `mcp-config` so it is covered by tests; this is the
/// door the frontend reaches it through, rather than a second implementation
/// in TypeScript that would drift from the tested one.
#[tauri::command]
pub fn parse_launch_options(text: String) -> Vec<String> {
mcp_config::split_launch_options(&text)
}

/// The arguments a launch would actually use, for display.
///
/// The app merges its own channel entry into what the person wrote, so the
/// line they typed is not the line that runs. This returns the line that runs.
#[tauri::command]
pub fn preview_launch_args(args: Vec<String>) -> Vec<String> {
channel_launch_args(&args)
}

/// What the caller gets back after a session joins.
#[derive(Debug, serde::Serialize)]
pub struct StartedSession {
Expand Down
42 changes: 38 additions & 4 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ const socketStateEl = document.getElementById("socket-state") as HTMLElement;
const sessionStateEl = document.getElementById("session-state") 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;
const previewEl = document.getElementById("launch-preview") as HTMLElement;

let tabs: TabConfig[] = [];
/** The session the terminal is attached to, once one is running. */
Expand Down Expand Up @@ -109,6 +111,33 @@ function fitTerminal(): void {
}
}

/** Render saved arguments back into an editable line. */
function joinArgs(args: string[]): string {
return args.map((arg) => (arg === "" || arg.includes(" ") ? `"${arg}"` : arg)).join(" ");
}

/**
* Show the command that will actually run.
*
* The app merges its own channel entry into whatever is typed here, so the
* line the person wrote is not the line that launches. Showing the result is
* cheaper than explaining the merge.
*/
async function refreshPreview(): Promise<void> {
const tab = tabs.find((candidate) => candidate.id === tabEl.value);
if (!tab) {
previewEl.textContent = "";
return;
}
try {
const parsed = await invoke<string[]>("parse_launch_options", { text: optionsEl.value });
const merged = await invoke<string[]>("preview_launch_args", { args: parsed });
previewEl.textContent = `${tab.command} ${joinArgs(merged)}`;
} catch {
previewEl.textContent = "";
}
}

function shortTime(iso: string): string {
const at = new Date(iso);
if (Number.isNaN(at.getTime())) return "";
Expand Down Expand Up @@ -220,7 +249,8 @@ async function startSession(): Promise<void> {
// 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 };
const args = await invoke<string[]>("parse_launch_options", { text: optionsEl.value });
const launching: TabConfig = { ...tab, cwd, args };

// 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.
Expand All @@ -236,6 +266,7 @@ async function startSession(): Promise<void> {
rows: terminal.rows,
});
tab.cwd = cwd;
tab.args = args;
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.
Expand Down Expand Up @@ -344,10 +375,13 @@ async function main(): Promise<void> {
// Only the prefill is lost; the field is still typed into by hand.
}

const showCwd = (): void => {
const showTab = (): void => {
const tab = tabs.find((candidate) => candidate.id === tabEl.value);
cwdEl.value = tab?.cwd ?? home;
optionsEl.value = joinArgs(tab?.args ?? []);
void refreshPreview();
};
optionsEl.addEventListener("input", () => void refreshPreview());

try {
const config = await invoke<AppConfig>("load_config");
Expand All @@ -358,8 +392,8 @@ async function main(): Promise<void> {
option.textContent = tab.name;
tabEl.appendChild(option);
}
showCwd();
tabEl.addEventListener("change", showCwd);
showTab();
tabEl.addEventListener("change", showTab);
} catch (err) {
status(`設定を読み込めませんでした: ${err}`, "error");
}
Expand Down
40 changes: 40 additions & 0 deletions src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -291,3 +291,43 @@ body {
#terminal .xterm-screen {
height: 100%;
}

/* ── launch options ──────────────────────────────────────────────────────── */

#launch-row {
display: flex;
align-items: center;
gap: 0.6rem;
border-bottom: 1px solid var(--line);
padding: 0.35rem 0.9rem 0.5rem;
font-size: 0.78rem;
}

#launch-row .options {
display: flex;
align-items: center;
gap: 0.35rem;
color: var(--muted);
}

#launch-row input {
width: 22rem;
font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
font-size: 0.75rem;
color: var(--fg);
background: var(--surface);
border: 1px solid var(--line);
border-radius: 4px;
padding: 0.25rem 0.45rem;
}

/* What will actually run, including the channel entry the app merges in. */
#launch-row .preview {
flex: 1;
color: var(--muted);
font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
Loading