From bdff03c811224fb2ccd0da89674f98698f8c8bcb Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 24 Aug 2026 09:36:30 +0900 Subject: [PATCH 1/2] feat(ui): give the terminal pane a header with tabs, close and size MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 診断面を独立した窓として扱えるようにする。上端にヘッダを置き、そこへ開いて いる端末のタブ、端末自身の文字サイズ、面を畳む ✕ を載せる。角丸は面 (#diagnostics) が持ち、端末 (#terminal) は上側の角を手放す。値は 8px で、 ヘッダの上端と面の下端の両方に同じ値が出る。 タブと参加者の行は連動する。どちらを押しても同じ端末が画面に出る。二つは renderPanel の一回の呼び出しから同時に描くため、連動は実装の約束ではなく 構造で成立する。同じ選択を二つの面が持つ重複は #59 の判断と逆を向くが、 Master が承知の上で両方を選んでいる。 「読んだ」の合図が移る。showView は終了した端末を「別のアカウントが選ばれた」 時点で捨てていたが (#57)、終了したタブに ✕ が付いたことで、その合図は暗黙の 選択から明示の操作へ移る。合図は失われず、置き場所が変わる。代償は閉じられる までスクロールバックがメモリに残ることであり、受容したトレードオフへ記録した。 走っているセッションのタブには ✕ を出さない。出せば「セッションの終了」と 読まれ、行の 終了 を端末側へ移さない制約を破る。 端末の文字サイズは寸法の三軸目である。会話面 (#60) とも UI 全体 (#66) とも 独立し、互いに相対で乗らない。localStorage の梯子 (9〜24px、既定 13px) で 持ち、変えるたびに fit を呼んで桁数と行数をセッションへ伝える。表示は px で ある。何桁入るかを決めるのがこの数そのものだからである。 #68 --- docs/0-requirements.md | 20 +++- index.html | 45 +++++++- src/main.ts | 252 ++++++++++++++++++++++++++++++++++++++--- src/styles.css | 152 +++++++++++++++++++++++-- 4 files changed, 441 insertions(+), 28 deletions(-) diff --git a/docs/0-requirements.md b/docs/0-requirements.md index 37c3067..ac99d3c 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -259,6 +259,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - アカウントごとの端末(リストの行で選ぶ、行からの終了、切り替えで残るスクロールバック) - 行のライフサイクル操作の記号表示(開始 ▶️ / 終了 ❌ / 編集 ⚙️、語は `aria-label` と `title`、幅を固定した列)と、終了の確認ダイアログ(webview 内の ``、取消に焦点、Escape は取消) - 診断面(アカウントごとの CLI の端末表示と入力) +- 診断面のヘッダ(開いている端末のタブ、行との連動、面を畳む `✕`、端末の文字サイズ)と、面全体の角丸。終了した端末はタブの `✕` で捨てる - Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD ### 実機で確認済み(2026-08-21) @@ -276,6 +277,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - アカウントごとの端末と終了の実機確認。2 セッションでの出力の分離、入力の宛先、切り替え後のスクロールバック、終了後の席の解放と再起動はいずれも実装済みであり、CI の型検査は通っているが、実機での操作は未確認である。 - 一つのリストとアカウントのモーダルの実機確認。種別ごとのグループと件数、オフラインの表示、行からの終了、モーダルの決定と取消、本人のアカウントへの移行はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。`hello` の `account_id` が実際の部屋で名簿へ届くところも未計測である。 - 会話面の文字サイズのうち、ヘッダのピッカー、再起動後の保持、上下限の実機確認。いずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。キー操作(`Ctrl` + `=` / `-` / `0`)と、効く範囲が会話面に留まること(参加者パネル・診断面・端末・入力欄が動かないこと)は 2026-08-24 の実機観察で確認済みである。後者が webview 自身のズームも閉じている——キーを受ければ寸法は無条件に動くため、動いたこと自体は既定動作を止められた証拠にならないが、会話面以外が一緒に動かなかった以上、webview のズームは走っていない。 +- 診断面のヘッダの実機確認。タブと行の連動、`✕` で面が畳まれセッションが残ること、終了したタブの `✕` での破棄、端末の文字サイズの梯子と再起動後の保持、桁数と行数がセッションへ伝わることはいずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。角丸と枠の見た目も同様に未確認である。 - 行の記号表示と終了の確認ダイアログの実機確認。三つの記号が Windows のフォントで揃って出ること、幅を固定した列で本人の行の名前が出ること、ダイアログの取消・終了・Escape はいずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。 - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) @@ -364,7 +366,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 会話面(`#room`)の文字サイズを、読む側が変えられる。 -**効く範囲は会話面だけである。** 参加者パネル・診断面・端末・入力欄の寸法は動かない。範囲は宣言ではなく置き場所で成立させる——寸法は `#room` 自身に載るカスタムプロパティであり、残る四つはこの要素の子孫ではないため継承経路が無い。`:root` に載せれば四つとも巻き込む。端末は特に巻き込めない。xterm.js は自前の寸法を持ち、そこから桁数と行数を計算するため、上から降ってきた寸法は表示だけでなく CLI が使える幅を変える。 +**効く範囲は会話面だけである。** 参加者パネル・診断面・端末・入力欄の寸法は動かない。端末は自分自身の寸法を別に持つが、それはこの軸ではない(下記「診断面」)。範囲は宣言ではなく置き場所で成立させる——寸法は `#room` 自身に載るカスタムプロパティであり、残る四つはこの要素の子孫ではないため継承経路が無い。`:root` に載せれば四つとも巻き込む。端末は特に巻き込めない。xterm.js は自前の寸法を持ち、そこから桁数と行数を計算するため、上から降ってきた寸法は表示だけでなく CLI が使える幅を変える。 **既定値は現在の見た目のままである。** `1rem`、つまりブラウザ既定の `16px` である。`.message .body` には `font-size` が無く、`body` にも `html` にも `:root` にも無いため、会話面はこれまでずっと既定で出ていた。まわり(タイトルバー `0.85rem` / 参加者パネル `0.72rem` 〜 `0.78rem` / 発言者名と時刻 `0.78rem` / 端末 `13px`)が寸法を与えられている中で、会話面だけが縮められていない。「チャットの文字が大きい」(実機観察、2026-08-23)はここから来ている。それでも既定は動かさない——設定していない画面が勝手に変わらないことを優先する。面どうしを揃え直すことは別の判断であり、この節が入れるのは変えられる仕組みのほうである。 @@ -401,7 +403,19 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 一つの端末を共有しない。共有すると、稼働中の全セッションの出力が一枚の画面へ書き込まれる。CLI はカーソル移動と部分再描画で出力するため、二つの再描画が重なった後の画面から片方を分離することはできない。「最後に起動したものが見える」のではなく、全部が重なる(#57)。 -終了した端末は、その場では消さない。落ちる直前の出力が、なぜ落ちたかを読む唯一の材料である。別のアカウントが選ばれた時点で捨てる。同じアカウントを再起動した場合も、前回の端末は新しい端末に置き換わる。 +**診断面は窓である。** 上端にヘッダを持ち、面全体が角丸の枠として畳まれる(#68)。角丸は面(`#diagnostics`)が持ち、端末(`#terminal`)は上側の角を手放す。値は `8px` で、ヘッダの上端と面の下端の両方に同じ値が出る。端末が自前の `4px` を持っていた形は、角の無い枠の中に角のある矩形を描いていた。ヘッダが載せるのは三つである——開いている端末のタブ、端末の文字サイズ、面を畳む `✕`。 + +**タブと参加者の行は連動する。** どちらを押しても同じ端末が画面に出る。同じ選択を二つの面が持つ形であり、#59 が名簿から外した重複とは逆を向く判断である(下記「受容したトレードオフ」)。タブが答えるのは「いまどの端末が開いているか」であり、行はそれを並べて読むことでしか答えない。二つは一つの描画から同時に出す。連動を実装の約束にせず、構造で成立させるためである。 + +**`✕` は面を畳む。セッションは終わらない。** 端末もリスナもスクロールバックも残り、開き直せば同じタブが並ぶ。ヘッダの `端末` ボタンは残す。畳まれた状態から開く手は `✕` の側に無く、二つは同じ操作の入口と出口である。セッションを終わらせる手は参加者の行の `終了` のままであり、端末側へは移さない。 + +**終了した端末は、タブが閉じられるまで残る。** 落ちる直前の出力が、なぜ落ちたかを読む唯一の材料である。「読んだ」の合図は、以前は「別のアカウントを選ぶ」という暗黙の選択だった(#57)。終了したタブに `✕` が付いたことで、その合図は明示の操作へ移る(#68)。合図そのものは失われず、置き場所が変わる。走っているセッションのタブには `✕` を出さない。出せば「セッションの終了」と読まれ、行の `終了` を端末側へ移さないという制約を破る。同じアカウントを再起動した場合は、前回の端末が新しい端末に置き換わる。 + +**端末の文字サイズは端末自身の寸法である。** 寸法の軸は三本あり、いずれも独立している——会話面(#60)、端末(#68)、UI 全体(#66)。互いに相対で乗らない。これは表示だけの話ではない。xterm.js は `fontSize` から桁数と行数を計算するため、変えるたびに `fit` を呼び、セッションへ新しい寸法を伝える。 + +保持は `localStorage`(`pullcept.terminal-font-size`)、段は離散の梯子(`9`〜`24px`)でその両端が上下限である。#60 と同じ形である。既定は `13px`——これまで端末が出ていた寸法であり、設定していない画面は変わらない。表示は `px` である。#60 が百分率で出すのは、会話面が何かに対して読まれるものではないためであり、端末は CLI の描く枠に対して読まれ、何桁入るかを決めるのはこの数そのものだからである。梯子に無い値が保存されていた場合は既定へ戻す(#60 と同じ理由)。 + +寸法は画面ごとの設定であって、セッションの属性ではない。画面上のすべての端末が同じ寸法で出る。隠れている端末は次に表示されたときに測り直す——隠れた枠には測る寸法が無いためであり、そのセッションへ新しい桁数が伝わるのもその時点である。 **終了はアカウントごとに行う。** 参加者のリストのその行から、そのアカウントのセッションを終了する。席の解放は別途要らない——`RoomSeats` は稼働の有無を PTY へ問うため、終了すれば席は空き、そのまま再起動できる(上記「アカウント」)。 @@ -607,6 +621,8 @@ CI が実行するもの: | Node サイドカー方式 (b) | プロセスが増える | | channel の早期採用 | experimental 仕様の変更がアプリへ波及する | | 第一段階の dev フラグ運用 | 起動ごとに警告バナーが出る。一般配布は不可 | +| 端末のタブと参加者の行が同じ選択を持つ(#68) | 一つの選択を二つの面が描く。#59 が名簿から外した重複と逆を向く | +| 終了した端末をタブが閉じられるまで残す(#68) | 放置されたタブのぶん、スクロールバックがメモリに残る。#57 は「読んだら消える」でこれを避けていた | ## 位置づけ diff --git a/index.html b/index.html index c84e4a4..8eb0274 100644 --- a/index.html +++ b/index.html @@ -55,8 +55,51 @@ + being watched (#57). + + A window, since #68: it carries a header of its own and the + rounding is on this element rather than on the terminal inside + it, so the frame that is folded and unfolded is one shape. --> diff --git a/src/main.ts b/src/main.ts index 9087791..b365b86 100644 --- a/src/main.ts +++ b/src/main.ts @@ -254,6 +254,9 @@ 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 tabsEl = document.getElementById("terminal-tabs") as HTMLElement; +const terminalFontSizeEl = document.getElementById("terminal-font-size") as HTMLSelectElement; +const diagnosticsCloseEl = document.getElementById("diagnostics-close") as HTMLButtonElement; const dialogEl = document.getElementById("account-dialog") as HTMLDialogElement; const dialogFormEl = document.getElementById("account-form") as HTMLFormElement; const dialogTitleEl = document.getElementById("account-dialog-title") as HTMLElement; @@ -309,16 +312,61 @@ let lastSeenId: string | null = null; /** The size the conversation is currently drawn at, in `rem`. */ let roomFontSize = DEFAULT_ROOM_FONT_SIZE; +/** + * How large a terminal is drawn, in `px`. + * + * The third size axis and an independent one: the conversation (#60), this, and + * the whole UI (#66) are three separate answers, and none of them is expressed + * relative to another. What makes this one different in kind from #60 is that it + * is not only a display size — xterm.js computes the session's columns and rows + * from it, so moving it changes the window the CLI is drawing for. + * + * `localStorage` and not the config, for #60's reason: it is a property of the + * screen being read from rather than of anybody in the room. + */ +const TERMINAL_FONT_SIZE_KEY = "pullcept.terminal-font-size"; + +/** + * The sizes a terminal can be set to, in `px`. + * + * A ladder with its ends as the bounds, the shape #60 settled for the + * conversation: there is no size off the ladder to clamp, so nothing separate + * enforces the limits. + * + * In `px` and labelled in `px`, where #60 labels a proportion. The two are + * asked different questions. A conversation is read against nothing in + * particular, so "larger or smaller than what I have" is the whole of it; a + * terminal is read against the CLI's own layout, and the number that decides how + * many columns fit is this one. It is also the unit xterm takes. + * + * Spread evenly rather than dense at one end. #60's rungs lean downward because + * the observation behind it was that the room reads large; nothing says which + * direction this one gets used in, and inventing a lean would be answering a + * question nobody has asked yet. + */ +const TERMINAL_FONT_SIZES = [9, 10, 11, 12, 13, 14, 16, 18, 20, 24]; + +/** + * Where a screen that has never chosen sits. + * + * `13px`, which is what every terminal has been opened at. Keeping it is the + * same completion condition #60 had: this adds the means to move, and moves + * nobody. + */ +const DEFAULT_TERMINAL_FONT_SIZE = 13; + /** * 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. + * the sessions rather than about the panes. The size is one of them: it is the + * screen's, not a session's, so opening a second terminal does not open it at + * some other size than the first (`openView` passes the current one). */ const TERMINAL_OPTIONS = { cursorBlink: true, - fontSize: 13, + fontSize: DEFAULT_TERMINAL_FONT_SIZE, fontFamily: 'ui-monospace, "Cascadia Mono", Consolas, monospace', // The CLI is a full-screen TUI: it moves the cursor, clears regions and // repaints. Anything less than an emulator turns that into debris, which is @@ -359,6 +407,8 @@ interface SessionView { const views = new Map(); /** The account whose terminal is on the glass, or null when none is. */ let shownAccount: string | null = null; +/** The size every terminal on this screen is currently drawn at, in `px`. */ +let terminalFontSize = DEFAULT_TERMINAL_FONT_SIZE; /** * Why an account's last launch failed, by account id, until it is tried again. * @@ -391,6 +441,20 @@ function revealDiagnostics(): void { requestAnimationFrame(() => fitShown()); } +/** + * Fold the pane away. Nothing under it stops. + * + * Every terminal keeps its session, its listeners and its scrollback, so the + * pane comes back with the same tabs on it. The two ways in and out are the + * 端末 button in the title bar and ✕ in the pane's own header: one is reachable + * while the pane is folded and the other while it is open, which is why both + * exist for one act (#68). + */ +function hideDiagnostics(): void { + diagnosticsEl.hidden = true; + toggleEl.setAttribute("aria-expanded", "false"); +} + /** The terminal currently on the glass, or null when none is. */ function shownView(): SessionView | null { return shownAccount === null ? null : (views.get(shownAccount) ?? null); @@ -530,6 +594,52 @@ function stepRoomFontSize(step: number): void { applyRoomFontSize(ROOM_FONT_SIZES[next], true); } +/** Fill the terminal's size picker. Labelled in `px`; see the ladder above. */ +function fillTerminalFontSizes(): void { + for (const size of TERMINAL_FONT_SIZES) { + const option = document.createElement("option"); + option.value = String(size); + option.textContent = `${size}px`; + terminalFontSizeEl.appendChild(option); + } +} + +/** + * The stored terminal size, or the default. + * + * Only a size on the ladder is honoured, for the reason `storedRoomFontSize` + * gives: a value off it cannot be stepped from, so the picker would stop working + * with nothing on screen saying why. + */ +function storedTerminalFontSize(): number { + const stored = Number(localStorage.getItem(TERMINAL_FONT_SIZE_KEY)); + return TERMINAL_FONT_SIZES.includes(stored) ? stored : DEFAULT_TERMINAL_FONT_SIZE; +} + +/** + * Draw every terminal at `size`, and remember it if it was chosen. + * + * Every one, not only the one on the glass. The size is the screen's, so a pane + * switched to later must not be the odd one out; and a terminal opened after + * this reads the same value (`openView`). + * + * The re-fit that follows only reaches the shown pane, which is the same limit + * `fitShown` has always had — a hidden container has no size to measure against. + * The others are laid out when they are next shown, because `showView` fits what + * it puts on the glass. Their sessions are told the new column count at that + * moment rather than this one. + * + * `save` is false for the restore at startup, so a screen that never chose is + * not given a stored size by being opened. + */ +function applyTerminalFontSize(size: number, save: boolean): void { + terminalFontSize = size; + for (const view of views.values()) view.term.options.fontSize = size; + terminalFontSizeEl.value = String(size); + if (save) localStorage.setItem(TERMINAL_FONT_SIZE_KEY, String(size)); + fitShown(); +} + /** * Fill a hue picker, with "not declared" first. * @@ -888,6 +998,102 @@ function editButton(account: Account): HTMLButtonElement { return edit; } +/** + * One tab: an open terminal, named by the account it belongs to. + * + * The name rather than the command it was launched from. The command is on the + * row's `title` and in the account's own form, and a strip of `claude` repeated + * once per session tells two sessions apart by nothing at all. + * + * The colour is the account's, the same one its lines carry in the room and its + * dot carries in the panel — which is what lets a tab and a row be read as one + * participant rather than as two names that happen to match. + * + * ✕ appears on an ended tab and on no other. On a running one it would be read + * as "end this session", and ending a session is 終了 on the row, asked in a + * dialog and answered there (#57 / #71); a second, plainer way to do it beside a + * control that merely changes what is showing is the slip those two were built + * against (#68). + */ +function terminalTab(view: SessionView): HTMLElement { + const name = viewName(view); + const account = accounts.find((one) => one.id === view.accountId) ?? null; + const shown = view.accountId === shownAccount; + + const tab = document.createElement("div"); + tab.className = "tab"; + // Never oneself: a terminal belongs to a session, and the person at this + // screen is not launched (`start_session` refuses a `user` account). + tab.style.setProperty("--speaker", speakerColor(name, account?.hue ?? null, false)); + if (shown) tab.classList.add("shown"); + if (view.ended !== null) tab.classList.add("ended"); + + const pick = document.createElement("button"); + pick.type = "button"; + pick.className = "name"; + pick.textContent = name; + pick.title = view.ended === null ? `${name} の端末` : `${name} の端末(${view.ended})`; + pick.setAttribute("aria-pressed", String(shown)); + pick.addEventListener("click", () => { + showView(view.accountId); + view.term.focus(); + }); + tab.appendChild(pick); + + if (view.ended !== null) { + const close = document.createElement("button"); + close.type = "button"; + close.className = "close"; + close.textContent = "✕"; + close.title = `${name} の端末を閉じる`; + close.setAttribute("aria-label", `${name} の端末を閉じる`); + close.addEventListener("click", () => closeView(view)); + tab.appendChild(close); + } + + return tab; +} + +/** + * Draw the tab strip: every terminal this screen holds, in launch order. + * + * Drawn from `views`, which is the same source the rows read to decide whether + * they are a picker — so the tabs and the rows cannot disagree about what is + * open. That they are two renderings of one selection is the duplication #59 + * removed from the roster and #68 chose here deliberately: the strip answers + * "which terminals are open" at the pane being looked at, and the rows answer it + * only by being read alongside it. Being drawn together is what keeps the + * accepted duplication from becoming a divergence. + */ +function renderTerminalTabs(): void { + tabsEl.replaceChildren(); + for (const view of views.values()) tabsEl.appendChild(terminalTab(view)); +} + +/** + * Close one ended terminal for good, from its tab. + * + * This is where "the person has read it" is said now. It used to be said by + * choosing another account — `showView` discarded an ended terminal the moment + * one was — and a tab that stays put until it is closed makes that an explicit + * act instead of a side effect of looking elsewhere (#68). The signal is not + * lost; it moved. + * + * What is on the glass afterwards is the most recently opened of what is left, + * which is the nearest neighbour in launch order. Nothing left is an honest + * answer too, and `showView(null)` is it. + */ +function closeView(view: SessionView): void { + const wasShown = shownAccount === view.accountId; + discardView(view); + if (wasShown) { + showView([...views.keys()].pop() ?? null); + return; + } + renderPanel(); + renderSessionFacts(); +} + /** * Draw the participant list: everyone who is here, and everyone who exists. * @@ -908,6 +1114,10 @@ function editButton(account: Account): HTMLButtonElement { * roster only, because a name that cannot be reached is not worth naming. */ function renderPanel(): void { + // The tabs are redrawn here rather than on their own schedule. Both surfaces + // read `views` and both mark the same selection, so drawing them from one call + // is what makes "they move together" true by construction (#68). + renderTerminalTabs(); rosterEl.replaceChildren(); const rows = members(); @@ -1243,7 +1453,10 @@ function openView(account: Account): SessionView { host.className = "term"; terminalEl.appendChild(host); - const term = new Terminal({ ...TERMINAL_OPTIONS }); + // At the size this screen is set to, not at the default in the options: a + // terminal opened after the size was changed would otherwise be the one pane + // that is a different size from the rest. + const term = new Terminal({ ...TERMINAL_OPTIONS, fontSize: terminalFontSize }); const fit = new FitAddon(); term.loadAddon(fit); term.open(host); @@ -1317,15 +1530,15 @@ function discardView(view: SessionView | undefined): void { * 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). + * A terminal whose session has ended is no longer the exception. It was + * discarded here, on the reasoning that choosing another account is the person + * saying they have read what it printed on its way out (#57). Its tab carries a + * ✕ now, so that saying is an act rather than a by-product of looking elsewhere, + * and `closeView` is where it lands (#68). The cost is that an ended terminal + * holds its scrollback until someone closes it — a tab left alone is memory held + * — and that is the accepted half of the trade. */ function showView(accountId: string | null): void { - 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; @@ -1787,14 +2000,21 @@ async function main(): Promise { stepRoomFontSize(step); }); + // Restored before any terminal is opened, so the first session is laid out at + // the size this screen reads at rather than being re-fitted once it lands. + fillTerminalFontSizes(); + applyTerminalFontSize(storedTerminalFontSize(), false); + terminalFontSizeEl.addEventListener("change", () => { + applyTerminalFontSize(Number(terminalFontSizeEl.value), true); + }); + + // The two ends of one act. 端末 is reachable while the pane is folded and ✕ + // while it is open, which is the whole of why both exist (#68). toggleEl.addEventListener("click", () => { - if (diagnosticsEl.hidden) { - revealDiagnostics(); - } else { - diagnosticsEl.hidden = true; - toggleEl.setAttribute("aria-expanded", "false"); - } + if (diagnosticsEl.hidden) revealDiagnostics(); + else hideDiagnostics(); }); + diagnosticsCloseEl.addEventListener("click", () => hideDiagnostics()); await listen("room-message", (event) => appendMessage(event.payload)); await listen("room-participants", (event) => renderRoster(event.payload)); diff --git a/src/styles.css b/src/styles.css index 346ef9d..0e7eaa7 100644 --- a/src/styles.css +++ b/src/styles.css @@ -281,11 +281,25 @@ body { /* Not the conversation surface. The room answers "what was said"; this answers "is anything under the room alive", which is the question a silent room - leaves unanswerable. */ + leaves unanswerable. + + A window rather than a strip since #68. The rounding is here, on the frame + that is actually folded and unfolded, and the header and the terminal are + clipped to it — the terminal had a 4px radius of its own and it drew a + rounded rectangle inside a square pane, which is a corner on nothing. The + margin is what makes the corners visible at all: an edge-to-edge pane has no + corner to round against the window. + + `overflow: hidden` is load-bearing, not tidiness. It is what puts the header's + top corners and the terminal's bottom corners on the radius declared here, + so one value holds both ends of the frame. */ #diagnostics { - border-top: 1px solid var(--line); + --pane-radius: 8px; + margin: 0 0.9rem 0.6rem; + border: 1px solid var(--line); + border-radius: var(--pane-radius); + overflow: hidden; background: var(--surface); - 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. This is also why the terminal stayed in this column instead @@ -294,7 +308,6 @@ body { height: 50vh; display: flex; flex-direction: column; - gap: 0.5rem; } /* The `display` above outranks the user agent's rule for the attribute, so @@ -304,6 +317,114 @@ body { display: none; } +/* ── the terminal window's header ────────────────────────────────────────── */ + +/* The tabs take the slack; the size picker and ✕ are pinned to the right, so + they are in one place whatever is open. `flex: none` is what keeps the header + its own height while the terminal below takes the rest of the pane. */ +#terminal-head { + display: flex; + align-items: center; + gap: 0.4rem; + flex: none; + border-bottom: 1px solid var(--line); + padding: 0.3rem 0.45rem; +} + +/* Scrolls sideways rather than wrapping. A second row of tabs would change the + pane's header height, and the terminal under it computes its rows from + whatever is left — opening one more session would resize every session's + window. */ +#terminal-tabs { + flex: 1; + min-width: 0; + display: flex; + align-items: center; + gap: 0.3rem; + overflow-x: auto; + scrollbar-width: thin; +} + +/* A tab is the account's name, in that account's colour: the same colour its + lines carry in the room and its dot carries in the panel, so a tab and a row + are read as one participant without matching the names letter by letter. */ +#terminal-tabs .tab { + display: flex; + align-items: center; + gap: 0.3rem; + flex: none; + max-width: 12rem; + font: inherit; + color: var(--muted); + background: none; + border: 1px solid transparent; + border-radius: 4px; + padding: 0.15rem 0.35rem; +} + +#terminal-tabs .tab .name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + cursor: pointer; +} + +/* Lifted onto the page's own surface, the way the selected row in the panel is + (`#participants .member.shown .pick`). The two selections are one selection, + so they are marked the same way. */ +#terminal-tabs .tab.shown { + color: var(--speaker, var(--fg)); + background: var(--bg); + border-color: var(--line); +} + +/* A session that has ended. Dimmed rather than dropped: what it printed on its + way out is the only account of why it went, and it stays readable until the + tab is closed (#68). */ +#terminal-tabs .tab.ended .name { + opacity: 0.7; +} + +/* Only on an ended tab. On a running one it would be read as "end this + session", which is 終了 on the row and stays there (#68). */ +#terminal-tabs .tab .close { + flex: none; + font: inherit; + font-size: 0.9em; + line-height: 1; + color: var(--muted); + background: none; + border: none; + border-radius: 3px; + padding: 0 0.15rem; + cursor: pointer; +} + +#terminal-tabs .tab .close:hover { + color: var(--fg); + background: var(--surface); +} + +#terminal-head select, +#terminal-head button { + font: inherit; + flex: none; + color: var(--fg); + background: var(--bg); + border: 1px solid var(--line); + border-radius: 4px; + padding: 0.1rem 0.25rem; + cursor: pointer; +} + +/* The pane's own way out. It folds the window and ends nothing — the session + under the terminal keeps running, so this is not drawn in the danger colour + that 終了 carries on the row. */ +#terminal-head #diagnostics-close { + line-height: 1; + padding: 0.15rem 0.4rem; +} + /* ── participants ────────────────────────────────────────────────────────── */ #participants { @@ -588,14 +709,27 @@ body { /* ── terminal ────────────────────────────────────────────────────────────── */ -/* The frame on the diagnostics pane. It stood after the launcher's own rules - until #62 removed them, under a heading that was never about it. */ +/* The terminal's own area, below the header. It stood after the launcher's own + rules until #62 removed them, under a heading that was never about it. + + It has no frame of its own any more: the border and the top corners are the + pane's (#68). What is left here is the bottom pair, which the pane's + `overflow: hidden` would clip anyway — they are written out so that the + radius is declared on both ends of the window rather than inherited by an + accident of clipping. + + `min-height` was `18rem`, the floor a TUI needs to draw a frame in. The pane + is a fixed `50vh` and now spends part of it on a header, so that floor is a + figure the terminal can be asked for and not given: on a short window the + column would total more than the pane and the excess would be clipped by the + rule above, silently. `0` lets this track shrink with the pane instead, which + is the height the pane actually has. What the floor was guarding is the pane's + `50vh`, and that is where it is enforced. */ #terminal { flex: 1; - min-height: 18rem; + min-height: 0; overflow: hidden; - border: 1px solid var(--line); - border-radius: 4px; + border-radius: 0 0 var(--pane-radius) var(--pane-radius); padding: 0.35rem; background: var(--bg); } From 9d2836fa79d41b40eeb7cf7ccc47893d15ad8a33 Mon Sep 17 00:00:00 2001 From: Claude Lin & Lay Date: Mon, 24 Aug 2026 09:41:29 +0900 Subject: [PATCH 2/2] fix(ui): drop the header fill, name the window, tighten the terminal inset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実機を見ての三点(Master、2026-08-24、#68 本文「実機を見ての追加指示」)。 ヘッダに色を敷かない。窓は全体が一つの地であり、それはページの地 (--bg) で あってパネルの色味 (--surface) ではない。面が --surface を持っていたのは帯で あった頃の名残であり、ヘッダを一色の帯として端末の上に置く形になっていた。 ヘッダであることは位置と中身が言う。窓と会話面を分けるのは枠線と角丸である。 地が入れ替わったため、ヘッダの操作子と選択中のタブは --surface で持ち上げる (タイトルバーの操作子と同じ形)。 ヘッダに窓の名前「端末」を出す。左端に置き、タブはその後ろに並ぶ。何も走って いないときにヘッダが自分について言うのはこの名前だけになる。 枠と端末の間の余白を 0.35rem から 0.15rem へ詰める。ここは余白ではなく端末が 使っていない面積であり、CLI の桁数と行数は残った寸法から計算される。0 には しない。xterm はカーソルと選択を自分の箱の縁まで描くため、枠に密着させると縁が 枠線の上へ乗る。 #68 --- docs/0-requirements.md | 10 ++++--- index.html | 16 ++++++++--- src/styles.css | 60 +++++++++++++++++++++++++++++++++--------- 3 files changed, 68 insertions(+), 18 deletions(-) diff --git a/docs/0-requirements.md b/docs/0-requirements.md index ac99d3c..100748a 100644 --- a/docs/0-requirements.md +++ b/docs/0-requirements.md @@ -259,7 +259,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - アカウントごとの端末(リストの行で選ぶ、行からの終了、切り替えで残るスクロールバック) - 行のライフサイクル操作の記号表示(開始 ▶️ / 終了 ❌ / 編集 ⚙️、語は `aria-label` と `title`、幅を固定した列)と、終了の確認ダイアログ(webview 内の ``、取消に焦点、Escape は取消) - 診断面(アカウントごとの CLI の端末表示と入力) -- 診断面のヘッダ(開いている端末のタブ、行との連動、面を畳む `✕`、端末の文字サイズ)と、面全体の角丸。終了した端末はタブの `✕` で捨てる +- 診断面のヘッダ(窓の名前「端末」、開いている端末のタブ、行との連動、面を畳む `✕`、端末の文字サイズ)と、面全体の角丸・一つの地・詰めた枠の余白。終了した端末はタブの `✕` で捨てる - Windows CI(`npm ci` と Rust コンパイル確認)、Release 公開時の CD ### 実機で確認済み(2026-08-21) @@ -277,7 +277,7 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre - アカウントごとの端末と終了の実機確認。2 セッションでの出力の分離、入力の宛先、切り替え後のスクロールバック、終了後の席の解放と再起動はいずれも実装済みであり、CI の型検査は通っているが、実機での操作は未確認である。 - 一つのリストとアカウントのモーダルの実機確認。種別ごとのグループと件数、オフラインの表示、行からの終了、モーダルの決定と取消、本人のアカウントへの移行はいずれも実装済みで、CI の型検査とテストは通っているが、実機での操作は未確認である。`hello` の `account_id` が実際の部屋で名簿へ届くところも未計測である。 - 会話面の文字サイズのうち、ヘッダのピッカー、再起動後の保持、上下限の実機確認。いずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。キー操作(`Ctrl` + `=` / `-` / `0`)と、効く範囲が会話面に留まること(参加者パネル・診断面・端末・入力欄が動かないこと)は 2026-08-24 の実機観察で確認済みである。後者が webview 自身のズームも閉じている——キーを受ければ寸法は無条件に動くため、動いたこと自体は既定動作を止められた証拠にならないが、会話面以外が一緒に動かなかった以上、webview のズームは走っていない。 -- 診断面のヘッダの実機確認。タブと行の連動、`✕` で面が畳まれセッションが残ること、終了したタブの `✕` での破棄、端末の文字サイズの梯子と再起動後の保持、桁数と行数がセッションへ伝わることはいずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。角丸と枠の見た目も同様に未確認である。 +- 診断面のヘッダの実機確認。タブと行の連動、`✕` で面が畳まれセッションが残ること、終了したタブの `✕` での破棄、端末の文字サイズの梯子と再起動後の保持、桁数と行数がセッションへ伝わることはいずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。ヘッダの存在・角丸・枠の見た目は 2026-08-24 に実機で観察され、そこから色を敷かないこと・窓の名前「端末」・枠の余白を詰めることの三点が指示として戻っている。三点を反映した後の見た目は再度未確認である。 - 行の記号表示と終了の確認ダイアログの実機確認。三つの記号が Windows のフォントで揃って出ること、幅を固定した列で本人の行の名前が出ること、ダイアログの取消・終了・Escape はいずれも実装済みで CI の型検査は通っているが、実機での操作は未確認である。 - 会話ログの永続化と観測 UI - plugin としての allowlist 掲載(配布の第二段階) @@ -403,7 +403,11 @@ liplus-desktop の `stream_parser.rs` および `spawn_stream_pty` / `spawn_stre 一つの端末を共有しない。共有すると、稼働中の全セッションの出力が一枚の画面へ書き込まれる。CLI はカーソル移動と部分再描画で出力するため、二つの再描画が重なった後の画面から片方を分離することはできない。「最後に起動したものが見える」のではなく、全部が重なる(#57)。 -**診断面は窓である。** 上端にヘッダを持ち、面全体が角丸の枠として畳まれる(#68)。角丸は面(`#diagnostics`)が持ち、端末(`#terminal`)は上側の角を手放す。値は `8px` で、ヘッダの上端と面の下端の両方に同じ値が出る。端末が自前の `4px` を持っていた形は、角の無い枠の中に角のある矩形を描いていた。ヘッダが載せるのは三つである——開いている端末のタブ、端末の文字サイズ、面を畳む `✕`。 +**診断面は窓である。** 上端にヘッダを持ち、面全体が角丸の枠として畳まれる(#68)。角丸は面(`#diagnostics`)が持ち、端末(`#terminal`)は上側の角を手放す。値は `8px` で、ヘッダの上端と面の下端の両方に同じ値が出る。端末が自前の `4px` を持っていた形は、角の無い枠の中に角のある矩形を描いていた。ヘッダが載せるのは、窓の名前「端末」と、開いている端末のタブ、端末の文字サイズ、面を畳む `✕` である。名前は左端に置き、タブはその後ろに並ぶ。何も走っていないときにヘッダが自分について言うのはこの名前だけになる。 + +**ヘッダに色を敷かない。** 窓は全体が一つの地であり、それはページの地(`--bg`)であってパネルの色味(`--surface`)ではない(実機観察、2026-08-24)。帯であった頃の名残として面が `--surface` を持っていたが、それはヘッダを一色の帯として端末の上に置く形だった。ヘッダであることは位置と中身が言う。窓と会話面を分けるのは枠線と角丸である。 + +**枠と端末の間の余白は詰める。** `0.15rem` である(実機観察、2026-08-24)。ここは余白ではなく端末が使っていない面積であり、CLI の桁数と行数は残った寸法から計算される。削った分は端末の面積になる。`0` にはしない——xterm はカーソルと選択を自分の箱の縁まで描くため、枠に密着させると縁が枠線の上へ乗る。 **タブと参加者の行は連動する。** どちらを押しても同じ端末が画面に出る。同じ選択を二つの面が持つ形であり、#59 が名簿から外した重複とは逆を向く判断である(下記「受容したトレードオフ」)。タブが答えるのは「いまどの端末が開いているか」であり、行はそれを並べて読むことでしか答えない。二つは一つの描画から同時に出す。連動を実装の約束にせず、構造で成立させるためである。 diff --git a/index.html b/index.html index 8eb0274..610fbef 100644 --- a/index.html +++ b/index.html @@ -61,9 +61,13 @@ rounding is on this element rather than on the terminal inside it, so the frame that is folded and unfolded is one shape. -->