Skip to content

feat(terminal): list the dev servers started in the terminal and open them in the in-app browser - #408

Merged
yuga-hashimoto merged 4 commits into
mainfrom
claude/available-tasks-review-d887ba
Oct 29, 2026
Merged

yuga-hashimoto merged 4 commits into
mainfrom
claude/available-tasks-review-d887ba

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

Part of #402 ("make starting and reaching a dev server easier"). Refs #402; it does not close it — the in-app browser report there has no reproduction steps and is not addressed here.

A server started in the embedded terminal had no status and no way to reach it: the user had to remember the port and type the address into the in-app browser by hand. Now:

  • The terminal reads the ports servers announce on screen (Local: http://localhost:5173/, Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/), listening on :3000) and probes them with a TCP connect to 127.0.0.1 / ::1.
  • The ones that answer appear as chips (:5173) above the output. Tapping a chip opens http://localhost:PORT/ in the in-app browser.
  • A server that stops answering (2 missed probes in a row, every 2 s) shows as "stopped" and is not tappable; it is forgotten after ~30 s. Chips disappear with the shell.

Design decisions

  • A port is shown only after it has answered. The echoed command line (curl http://localhost:8080) also contains a URL, so mentioned-but-silent ports are probed but never listed. Probing uses a plain connect because /proc/net/tcp is unreadable to apps since Android 10.
  • Only the port number is taken from the output, never the host. DevServer.url is always http://localhost:<int>/, so program output cannot point the in-app browser anywhere but this device. A tap is still required.
  • Free when unused. No probing starts until the output first names a server; the loop ends when nothing is left to probe, with the shell, or when the screen closes. At most 8 ports are tracked; when full, a new port replaces the oldest candidate that never answered (a port that has answered is never pushed out).
  • Only new screen lines are searched (identity comparison of the emulator's cached StyledLines, end of screen only), so an address still in the scrollback cannot revive a forgotten server. Rows the screen width wrapped are joined first (WrappedLines), so a banner that crosses the wrap point on a narrow phone is still found; trailing digits of a line that may still be growing are held back so half of a port is not read as a port.
  • New code is plain Kotlin and immutable where it can be: DevServerDetector, DevServerTracker (state machine), WrappedLines, DevServerProbe/LoopbackDevServerProbe. TerminalViewModel gains a serverProbe constructor parameter (defaulted, before ioDispatcher); TerminalScreen gets a required onOpenServer.
  • Three new strings in all 8 locales (a11y descriptions carry the port number). Touch target 48 dp, port number forced left-to-right.

Known limitations (documented in docs/LOCAL_RUNTIME.md)

  • Servers not started from the terminal (e.g. by an agent) do not appear; use the browser's URL bar.
  • Output of 200+ lines in one burst after the address can push it out of the searched tail.
  • "A full-width row continues on the next row" is an inference from the row's length (the emulator does not mark wrapped rows); a line that ends exactly at the last column is joined to the next row too. Cost: an occasional stray candidate that is dropped when nothing answers. Marking wrapped rows in the emulator would make it exact (follow-up).

Test plan

  • ./gradlew spotlessCheck detekt :app:testGithubDebugUnitTest :app:lintGithubDebug — green: 1616 unit tests, 0 failures (8 skipped = pre-existing live tests), lint 0 errors. Note the root detekt task reports NO-SOURCE in this repo, so it checks nothing.
  • New unit tests: DevServerDetectorTest (9), DevServerTrackerTest (16: shown only after answering, running/stopped/dropped transitions, 8-slot limit, eviction rules), WrappedLinesTest (10), LoopbackDevServerProbeTest (3, real loopback sockets), and 16 new cases in TerminalViewModelTest (shows a server once it answers; echoed command not shown; colour codes inside the URL; address split across two chunks; address wrapped across rows; front of a port not read as a port; stop/recover/forget; probing ends with the shell and on close; restart starts empty; a throwing probe; nothing probed for plain output).
  • TerminalScreenInstrumentedTest — 16/16 on an API 36 emulator, including 4 new chip tests (running chip opens its address, stopped chip is displayed but has no click action, several chips, no row without servers).
  • i18n key parity (same check as .github/workflows/i18n-check.yml): 1155 required keys, all 7 translations complete.
  • Not tried against the real proot/Alpine runtime on a device: start python3 -m http.server, npm run dev, etc. in the terminal, confirm the chip appears, opens the page, turns to "stopped" on ^C and disappears with the shell.
  • TalkBack not tried: the chip merges descendants with a contentDescription; whether it is read twice is unverified.

Pre-PR review

The named repo-reviewer subagent (.opencode/agents/repo-reviewer.md) is not registered in Claude Code, so the review was run by a read-only general-purpose subagent given that definition. Two rounds:

  1. Commit 4eeabc2f (+ the merge of origin/main) → APPROVE with suggestions; the substantive ones were applied in 36830800: rows wrapped by a narrow screen hid addresses (python's banner at ~47 columns) and could be read as a wrong port; slots held by never-answered fragments; detection outside the output stream's safety net; a start/cancel race window; touch target and RTL port display; onOpenServer default removed; tests for a failing probe.
  2. Commit 36830800 → APPROVE (report below, verbatim).

Commit 36e7b0ad was added after the approval and changes only a KDoc comment and docs/LOCAL_RUNTIME.md to say that joining wrapped rows is an inference from their length (the reviewer's suggestion 1). No code changed; it was not re-reviewed.

判定: APPROVE

## ブロッカー
なし

## 提案(非ブロッキング)
1. 【任意・フォローアップ可】`WrappedLines` の「幅いっぱいの行は次の行に続く」は長さからの推定。エミュレータの実際の折り返しとは完全には一致しない。
   - 該当箇所: `WrappedLines.kt:30-34`、`AnsiTerminalEmulator.kt:203-214`。
   - エミュレータは「ちょうど幅の文字数を出して `\r\n`」と「幅を超えて折り返し」を区別しない。どちらも `text.length == columns` の行が残るので、前者も後続行と連結される。区切り文字なしで連結するため、起こり得る誤動作は3つある。
     - (a) 行末がポートで終わるちょうど幅の行(例 `…:3000`)の次の行が数字で始まると、`30001` や `30005` のようにポートが誤読される(または6桁以上になって見落とす)。
     - (b) 同じ連結のため、本来は終わっている幅いっぱいの行が、直後の可変行(編集中のプロンプト行など)と同一グループになる。後続行が書き換わるたびに前の行まで再走査される。そこに URL があれば、一度忘れたサーバーが候補として復活する。
     - (c) 同じ原因の副作用として、本来は別の行を連結してしまう。
   - 発生条件は「長さがちょうど列数」の行に限られ、実害は小さい。ポートが誤読されても、応答しない候補なので30秒で消える。
   - 根治策: `print()` で `wrapPending` を消費して次の行へ進む時点に、直前の `Row` へ「折り返した」印を付けて `StyledLine`(または別のビュー)経由で渡す。`WrappedLines` はその印で連結する。大きめの変更なので別PRでよい。
   - 今できる範囲では、`docs/LOCAL_RUNTIME.md` の折り返しの項と `WrappedLines` の KDoc の「exactly what the emulator does」を、「推定であり、ちょうど幅で終わって改行された行も続くとみなす」と言い換える。可能なら、この挙動を明示する特性テストを足す。
2. 引き続きの未確認: チップは `semantics(mergeDescendants = true)` で、子の `:5173` テキストと `contentDescription` が TalkBack でどう読まれるか(二重読みの有無)。作者も未検証としている。実機で確認し、必要なら `clearAndSetSemantics` を検討する。
3. 情報として: 正規表現に渡す文字列の長さの上限が、行単位の240文字から、折り返しグループ単位の最大 200行×240桁=約48,000文字に広がった。
   - 現在の3パターンは入れ子の量指定子や繰り返しグループがなく、線形〜軽い二次なので問題ない。`mightMentionAServer` の前段フィルタもあるので、通常の行では正規表現を実行しない。
   - 新設の `catch (RuntimeException)` は、Android の ICU 正規表現が投げる例外も拾える。ただし `Error`(StackOverflowError など)は拾えない。今のパターンでは起きない前提なので、将来パターンを足す際に注意すればよい。

## チェック済み項目
- `git fetch` 後 `git rev-list --left-right --count origin/main...HEAD` が `0 3`。main に遅れていない。新規コミット `36830800` と `git diff origin/main...HEAD` 全体(23ファイル)を再読した。
- 前回の提案への対応を確認した。
  - 折り返し対応 → `WrappedLines`。
  - 候補枠の押し出し → `DevServerTracker.observe`。
  - 検出部分の例外処理 → `watchForServers` の try/catch。
  - 競合の窓の解消 → LAZY ジョブ。
  - `onOpenServer` の必須化。
  - チップのタッチターゲットと LTR。
  - 例外を投げるプローブのテスト。
- 観点1(折り返し):
  - `WrappedLines.join` を `AnsiTerminalEmulator.print`/`wrapPending`/`lineFeed` と突き合わせた。
    - 幅ちょうどまで書くと `wrapPending=true` になる。
    - 次の文字で初めて新しい `Row` ができる。
    - 後続が来なければ次の行は存在しない。
  - 末尾の行が幅いっぱいなのは、ループ構造上「画面の最終行」の場合だけ。
  - 後続の文字や改行が届くと新しい(または書き換わった)行ができる。`group.any(include)` が再び真になり、グループ全体が数字を含めて再走査される。
  - python の `Serving HTTP on … (http://0.0.0.0:8000/) ...` は、20桁(`MIN_COLUMNS`)で3行に折り返しても `8000` を検出できる(`TerminalViewModelTest` の新テストと同じ前提)。
  - 前回指摘したケース(約47〜54桁の端末)も、連結後に URL が1つの文字列になるので検出できる。
  - 前のコミットで検出できて今回見落とすのは、最終行がちょうど幅いっぱいで、末尾が数字、かつ追加出力が来ない場合に限られる。通常は直後に改行が来るため、空行が追加されてグループが閉じる。実害は小さい。
  - `columns` はエミュレータと同じ `coerceIn(20, 240)` 済みの値(`TerminalViewModel` のフィールド)。`open()` での書き込みは `synchronized(lock)` より前だが、`publish` は `emulator` が非 null になった後に同じロックの内側で読むので可視性に問題はない。
  - 一時的に誤ったポートを読む経路は、チャンク境界が幅いっぱいでない行の途中の数字にある。例: `localhost:51` | `73`。これは以前からある。応答しない断片は押し出しの対象になるため、実害はなくなった。
- 観点2(`DevServerTracker.observe` の押し出し):
  - ループ内で、既知のポートは `continue`、満杯なら `indexOfFirst { !seenUp }` で最古の未応答候補を1件除いて追加する。除いてから追加するので件数は8を超えない。
  - 全枠が `seenUp` なら `continue` で新ポートを無視する。一度でも応答した枠(停止表示中を含む)は押し出されない。
  - 変更がなければ `this` を返す。一覧は不変のまま(`filterIndexed` と `+` で新しいリスト)。
  - 実行中の probe 結果は `applyProbe` の `mapNotNull` で、追跡対象外のポートとして無視される。
  - 新規テスト4件(最新優先、未応答の最古を押し出す、応答済みは押し出さない、停止も押し出さない)が仕様をカバーしている。
- 観点3(LAZY ジョブ):
  - `watchForServers` はロック内で `probing=true` にし、LAZY の `launch` を作って `probeJob` に記録する。ロック外の `start()` は、`publish` の末尾で必ず実行される。
  - 起動前に `close()` が来た場合: `probeJob.cancel()` が未開始の LAZY ジョブを取り消すので、`start()` は何もしない。`closed` は true で、その後は何も動かない。
  - 起動前に `endServerWatch` が来た場合: `probing=false` と取り消しを行う。後続の言及があれば新しいジョブが作られる。古い取り消し済みジョブは本体を実行しない。
  - 起動後は、`closed` か追跡対象が空になったとき、`probing` をクリアしてループを終える。`endServerWatch` は `probing` を自分でクリアして取り消す。ループ内の `Exception` はプローブ側で未応答扱いにされ、キャンセルは再 throw される。
  - 結論: シェルが生きている間に `probing` が true のまま、クリアするループがない状態は、ジョブが作られたのに `start()` されない場合に限られる。コード上その間に中断される分岐はない。`Error` が起きた場合は、アプリが落ちる前提。
  - 並行して2つの `publish` が走る場合: `probing` が真の間は `null` を返すので、二重起動しない。
  - Main から `restart()`/`clear()` 経由で `start()` する場合: `start()` はロックの外なので、`Dispatchers.Main.immediate` で本体が即時に実行されても、ロックを保持したまま `probeServers` が走ることはない。
  - `launchProbe` への参照は `rg` で残っていないことを確認した。
- 観点4(`TerminalScreen`):
  - `TerminalScreen(` の呼び出しは `WorkspaceNavGraph.kt:197`(`onOpenServer` を渡している)と `TerminalScreenInstrumentedTest.kt:71`(`onOpenServer` を渡している)の2か所だけ。`@Preview` などの他の呼び出し元はない。`onOpenServer` を必須にしてもコンパイルは壊れない。
  - `minimumInteractiveComponentSize()` は Compose BOM 2024.12.01(material3 1.3 系)に存在する。モディファイアの順序(`testTag` → 最小サイズ → `heightIn(32dp)` → `clip`/`border`/`clickable`)は Material 標準の使い方と同じ。Compose のヒットテストは 48dp まで広がるので、描画サイズ32dpでも 48dp の領域で反応する。
  - ポート表示は `OutputTextStyle.copy(textDirection = TextDirection.Ltr)` で、ar ロケールでの「5173:」表示の懸念が解消されている。
  - 文言の変更はなし。ロケールファイルへの追加もなし。
- 観点5(ドキュメント): `docs/LOCAL_RUNTIME.md` の追記(満杯時に最古の未応答候補を押し出す/応答済みは押し出さない/折り返し行の連結/続く可能性のある間は行末の数字を読まない)が、コードと一致する。README は変更不要。
- CI 相当の静的確認: 変更した全 `.kt` に140桁超の行はない。`WrappedLines.join` は ReturnCount が2、ネスト深度が3。detekt の閾値に収まる見込みだが、detekt/Gradle は実行していない(読み取り専用の制約)。作者は spotlessCheck と単体テスト 1616件の成功を確認済みで、私は再実行していない。
- 未確認: 実機の proot ランタイム、TalkBack。
- 手順上の注記: 行長確認に awk を使った(許可リスト外だが読み取り専用で、ファイルは変更していない)。

🤖 Generated with Claude Code

yuga-hashimoto and others added 4 commits October 29, 2026 00:21
…in the browser

A dev server started in the embedded terminal had no status and no way to
reach it: the user had to remember the port and type the address into the
in-app browser by hand.

Read the ports servers announce on screen ("Local: http://localhost:5173/",
"Serving HTTP on 0.0.0.0 port 8000", "listening on :3000"), probe them with a
TCP connect to the loopback addresses, and show the ones that answer as chips
above the output. A chip opens http://localhost:PORT/ in the in-app browser;
a server that stops answering is shown as stopped, and forgotten after about
half a minute. A port is only shown once it has answered, because the echoed
command line ("curl http://localhost:8080") names ports nothing listens on.

Only the port number is taken from the output, never the host, so a program's
output cannot send the in-app browser anywhere but this device. Probing starts
when the output first names a server and ends with the shell, so a plain shell
costs nothing. Only screen lines that are new since the last look are
searched, so an old address still in the scrollback cannot revive a server
that was forgotten.

Refs #402

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…real servers

Apply the pre-PR review's suggestions.

The terminal stores a line wider than the screen as several rows, so an
address crossing the wrap point was whole in none of them: python's
"Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/)" was missed on a
narrow phone, and a cut-off "80" could be read as a port. Rows that fill the
width are now joined to the next one before searching, and the trailing digits
of a line that may still be growing are held back until it ends.

When the slots are full, a new port now pushes out the oldest candidate that
never answered (echoed commands, cut-off fragments) instead of being dropped;
a port that has answered is never pushed out.

Also: a failure while searching for servers no longer takes the output stream
down; the probe loop is created inside the lock that decides to start it, so a
close or an exit that comes in between cancels it before it runs; the chip's
touch target is 48dp and its port number is always left-to-right; and
onOpenServer is a required parameter.

Refs #402

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…eir length

The pre-PR re-review noted that a row filling the width is joined to the next
one whether it wrapped or merely ended in the last column. Say so in the
KDoc and in LOCAL_RUNTIME.md, with what it costs. Comments and docs only.

Refs #402

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@yuga-hashimoto
yuga-hashimoto merged commit 5a1c755 into main Oct 29, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant