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
2 changes: 1 addition & 1 deletion README.ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ git リポジトリの中で実行する。

| フラグ | 説明 |
|---|---|
| `--repo owner/name` | 対象リポジトリ。デフォルトはカレントディレクトリのリポジトリ |
| `--repo owner/name` | 対象リポジトリ。指定すると Repos タブから始まる。デフォルトはカレントディレクトリのリポジトリで、この場合は Work タブから始まる |
| `--lang en\|ja` | 表示言語。デフォルトはオペレーティングシステムのロケール |
| `--icons unicode\|nerd\|ascii` | グリフの種類。デフォルトは `unicode`。`OCTOSCOPE_ICONS` で恒久的に指定できる |
| `--version` | バージョンを表示して終了する |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ Or point it at any repository:

| Flag | Description |
|---|---|
| `--repo owner/name` | Target repository. Defaults to the repository of the current directory. |
| `--repo owner/name` | Target repository, and the tab octoscope opens on. Defaults to the repository of the current directory, and to the Work tab. |
| `--lang en\|ja` | Display language. Defaults to the operating system locale. |
| `--icons unicode\|nerd\|ascii` | Glyph set. Defaults to `unicode`; `OCTOSCOPE_ICONS` sets it permanently. |
| `--version` | Print the version and exit. |
Expand Down
62 changes: 62 additions & 0 deletions docs/superpowers/plans/2026-09-07-phase2-followups.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Phase 2 追補 実装計画: 読み込み中の入力と、`--repo` のときの初期タブ

Part 1 / Part 2 のあとに残った 2 件。どちらも `internal/tui` だけで閉じる。

## 分かっていること(2026-09-07 に実測)

python の `pty.fork()` + `TIOCSWINSZ` で実端末を作り、実 API を相手に確認した。

- **詳細画面の `c` は「本文の読み込み中」だけ無視される。** 同じ pty・同じキーで、
`enter` の 4 秒後に押した `c` は何も起きず、25 秒後に押した `c` は入力欄を開いた。
読み込み中の画面はスピナー 1 行だけで、なぜ無視されたかはどこにも出ない。
これが「`c` が反応しない」という報告の説明になる(設計 §3.4 の
「キーイベントが `"c"` にならない」という仮説より単純で、証拠がある)
- **`--repo` を渡しても Work タブから始まる。** これは spec §4 の
「起動直後は Work タブ」どおりの動作である

## Task 1: 読み込み中に断ったことを画面に出す

`diff` は同じ状況を `declined` の 1 行で見せている(`decline_loading`)。
`detail` にも同じ流儀を入れる。

- `internal/tui/detail/detail.go`
- `Model` に `declined string` を足す(`diff` と同じ名前・同じ役割)
- `handleKey` の `phase == phaseLoading` で断る 5 箇所(`c` / `x` / `v` / `l` / `a`)で
`m.declined = i18n.T("detail.decline_loading")`
- `itemArrived` で `m.declined = ""`。断った理由が消えないまま残らないようにする
- `internal/tui/detail/render.go`
- `phaseLoading` の画面(スピナー 1 行)の下に `declined` を 1 行足す
- `internal/i18n/locales/active.{en,ja}.yaml`
- `detail.decline_loading` を両方に足す
- テスト(`internal/tui/detail/detail_test.go`)
- 読み込み中の `c` で `View()` にその 1 行が出る
- `itemMsg` が届いたら消える
- 書いた直後に、`declined` の代入を消して落ちることを確認する
- golden: `detail_loading_*` は表示が変わるので録り直す。`make golden` の diff を目で見る

**検証**: `make check` が通り、pty で読み込み中に `c` を押してその行が出ること。

## Task 2: `--repo` のときは Repos タブから始める

**先に spec を直す。** `docs/superpowers/specs/2026-09-05-octoscope-standalone-design.md`
§4 の「起動直後は Work タブ」を「対象リポジトリが `--repo` で決まっているときは
Repos タブ、それ以外は Work タブ」に変える。§3.4 の優先順とも読み合わせる。

- `internal/tui/app/app.go`
- `New` で `opts.HasRepo` なら `tab = tabRepos`
- `repoResolvedMsg` で後から Repos タブが増えたときは**タブを移さない**。
利用者が既に見ている画面を奪わないため
- テスト(`internal/tui/app/app_test.go`)
- `HasRepo: true` の初期タブが `tabRepos`、`false` は `tabWork` のまま
- 既存の `app_test.go:180` はこの新しい規則に合わせて書き換える
- golden: `app` の初期フレーム(`withRepo`)が変わるので録り直す
- README(`README.md` / `README.ja.md`)に `--repo` の説明があれば追随する

**検証**: `make check`、`git diff` した golden を目で見る、pty で `--repo` 付きの
起動が Repos タブで始まること。

## この計画に入れないもの

- `commentPostedMsg` / `commentErrorMsg` / `stateChangedMsg` / `stateErrorMsg` の
ref ガード(picker と同じ穴だが、今回の指摘は picker だった)
- `--debug-keys`。Task 1 の再現で原因の説明が付いたので、まだ足さない
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,19 @@ Herdr のコンテキストに代わり、次の優先順で決定する。

## 4. 画面構成

3 つのタブを並列に持ち、`1` / `2` / `3` で切り替える。起動直後は Work タブ。
3 つのタブを並列に持ち、`1` / `2` / `3` で切り替える。
タブごとに役割が違うため、共通のレイアウトを被せず、それぞれに合った形を採る。

**起動直後のタブは、対象リポジトリの決まり方で変わる。**

- `--repo` で明示されているときは **Repos タブ**。フラグを渡すのは
「このリポジトリを見に来た」という意思表示であり、そこから始めるのが素直である
(2026-09-07 に利用者と決めた)
- そうでないときは **Work タブ**。カレントディレクトリの git remote は起動後に
解決されるので、最初のフレームの時点では対象リポジトリが決まっていない。
あとから解決して Repos タブが増えても、**タブは移さない** — 利用者が既に
見ている画面を奪わないため

タブは一度に全部揃うわけではない。Search タブは Phase 4 で追加されるまで存在しない。
また `--repo` フラグも git remote も無く対象リポジトリが決まらないときは、Repos タブ
自体を出さず(§3.4 参照)、Work タブだけで動く。
Expand Down
13 changes: 13 additions & 0 deletions docs/superpowers/specs/2026-09-06-phase2-remediation-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,19 @@ xdg-open なし
**次の手**: `--debug-keys`(受け取ったキーの `String()` をファイルに落とす)を
一時的に足して実機で確認する。原因が特定できるまでこの項目は未解決として残す。

### 3.4.1 解決(2026-09-07): 本文の読み込み中に押していた

`--debug-keys` は不要だった。python の `pty.fork()` で実端末を作り、実 API を
相手に再現したところ、**同じキーが読み込み中は無視され、届いたあとは入力欄を開いた**。

- `enter` の 4 秒後に押した `c`: 何も起きない(画面はスピナー 1 行)
- 同じ手順で 25 秒後に押した `c`: 入力欄が開く

`handleKey` は `c` / `x` / `v` / `l` / `a` を `phase == phaseLoading` で無視するが、
そのときの画面はスピナーだけで、なぜ無視されたかがどこにも出ていなかった。
**処置**: `detail.decline_loading` を出す(`diff` の `declined` と同じ流儀)。
IME やキーボードプロトコルの仮説は取り下げる。

## 4. 設計

### 4.1 パッケージ構成と依存の向き
Expand Down
2 changes: 2 additions & 0 deletions internal/i18n/locales/active.en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ detail:
other: "PR #{{.Number}} {{.Title}}"
issue_title:
other: "Issue #{{.Number}} {{.Title}}"
decline_loading:
other: "the item is still on its way; try that key again in a moment"

compose:
title:
Expand Down
2 changes: 2 additions & 0 deletions internal/i18n/locales/active.ja.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,8 @@ detail:
other: "PR #{{.Number}} {{.Title}}"
issue_title:
other: "Issue #{{.Number}} {{.Title}}"
decline_loading:
other: "本文がまだ届いていません。少し待ってからもう一度"

compose:
title:
Expand Down
12 changes: 11 additions & 1 deletion internal/tui/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -138,12 +138,20 @@ type Model struct {
}

func New(src Source, opts Options) Model {
return Model{
m := Model{
src: src,
opts: opts,
work: work.New(src),
repo: repo.New(src),
}
// Naming a repository on the command line is a statement about what the
// user came to look at, so that is the tab they land on. A repository
// found later, from the working directory, does not move them: see
// repoResolved.
if opts.HasRepo {
m.tab = tabRepos
}
return m
}

// Init asks the terminal for its background colour and nothing else: the
Expand Down Expand Up @@ -202,6 +210,8 @@ func (m Model) repoResolved(msg repoResolvedMsg) (tea.Model, tea.Cmd) {
m.repoLookupTimedOut = msg.timedOut
return m, nil
}
// The Repos tab appears, but the user stays where they are: the answer
// arrives seconds after the board is already on screen.
m.opts.HasRepo = true
// broadcast skips the list until this point, so it never saw the
// WindowSizeMsg that told the others how wide they are: an unsized
Expand Down
49 changes: 34 additions & 15 deletions internal/tui/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -176,19 +176,37 @@ func isQuit(cmd tea.Cmd) bool {
return ok
}

func TestStartsOnTheWorkTab(t *testing.T) {
if m := newTestModel(Options{HasRepo: true}); m.tab != tabWork {
t.Errorf("tab: got %v, want tabWork", m.tab)
// TestTheFirstTabFollowsTheFlag covers both halves of the rule: --repo says
// which repository the user came for, so they land on it; without the flag
// there is no repository yet to land on.
func TestTheFirstTabFollowsTheFlag(t *testing.T) {
if m := New(&fakeSource{}, Options{HasRepo: true}); m.tab != tabRepos {
t.Errorf("with --repo: tab = %d, want tabRepos", m.tab)
}
if m := New(&fakeSource{}, Options{HasRepo: false}); m.tab != tabWork {
t.Errorf("without --repo: tab = %d, want tabWork", m.tab)
}
}

// TestAResolvedRepositoryDoesNotMoveTheUser is the other half: the working
// directory's repository is answered seconds after the board is already on
// screen, and swapping the tab under the user then is not a courtesy.
func TestAResolvedRepositoryDoesNotMoveTheUser(t *testing.T) {
m := newTestModel(Options{HasRepo: false})
next, _ := m.Update(repoResolvedMsg{found: true})
if got := next.(Model); got.tab != tabWork {
t.Errorf("tab = %d after the repository was resolved, want tabWork", got.tab)
}
}

func TestTabKeysSwitchTabs(t *testing.T) {
m := press(newTestModel(Options{HasRepo: true}), "2")
if m.tab != tabRepos {
t.Errorf("after 2: got %v, want tabRepos", m.tab)
// --repo starts on Repos, so 1 is the key that has somewhere to go first.
m := press(newTestModel(Options{HasRepo: true}), "1")
if m.tab != tabWork {
t.Errorf("after 1: got %d, want tabWork", m.tab)
}
if m = press(m, "1"); m.tab != tabWork {
t.Errorf("after 1: got %v, want tabWork", m.tab)
if m = press(m, "2"); m.tab != tabRepos {
t.Errorf("after 2: got %d, want tabRepos", m.tab)
}
}

Expand Down Expand Up @@ -733,7 +751,7 @@ func TestKeysReachTheTabUnderneath(t *testing.T) {
src := &fakeSource{work: gh.Work{
gh.SectionReviewRequested: {{Ref: gh.ItemRef{Kind: gh.ItemPR, Number: 1}, Title: "first"}},
}}
m := New(src, Options{HasRepo: true})
m := New(src, Options{}) // no --repo: the board is the first tab
next, cmd := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = resolve(t, next.(Model), cmd)

Expand All @@ -760,7 +778,7 @@ func TestEnterOnTheBoardOpensTheDetailView(t *testing.T) {
},
pr: gh.PR{Number: 41, Title: "add the work board", State: gh.StateOpen},
}
m := New(src, Options{HasRepo: true})
m := New(src, Options{}) // no --repo: the board is the first tab
next, cmd := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
m = resolve(t, next.(Model), cmd)

Expand Down Expand Up @@ -822,13 +840,14 @@ func renderEveryScreen(t *testing.T, width int) map[string]string {
size := tea.WindowSizeMsg{Width: width, Height: 40}

src := overlongSource()
// --repo opens on the Repos tab; 1 is what reaches the board from there.
next, cmd := New(src, Options{HasRepo: true}).Update(size)
board := resolve(t, next.(Model), cmd)
reposM := resolve(t, next.(Model), cmd)

repos, cmd := board.Update(key("2"))
repos = resolve(t, repos.(Model), cmd)
next, cmd = reposM.Update(key("1"))
board := resolve(t, next.(Model), cmd)

item, cmd := repos.Update(key("enter"))
item, cmd := reposM.Update(key("enter"))
item = resolve(t, item.(Model), cmd)

failed, _ := board.Update(work.ErrorMsg{Err: errors.New(overlongTitle)})
Expand All @@ -846,7 +865,7 @@ func renderEveryScreen(t *testing.T, width int) map[string]string {

return map[string]string{
"work": content(board),
"repos": content(repos.(Model)),
"repos": content(reposM),
"detail": content(item.(Model)),
"error": content(failed.(Model)),
"error_overlay": content(overlayFailed.(Model)),
Expand Down
5 changes: 5 additions & 0 deletions internal/tui/app/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,14 @@ func TestGolden(t *testing.T) {
i18n.SetLanguage(lang.tag)
t.Cleanup(func() { i18n.SetLanguage(language.English) })

// --repo starts on the Repos tab, so the board's own first
// frame is reached with 1.
withRepo := goldenModel(w, Options{HasRepo: true})
golden.Assert(t, fmt.Sprintf("app_tabs_%s_%d", lang.name, w), withRepo.View().Content)

onWork := press(withRepo, "1")
golden.Assert(t, fmt.Sprintf("app_tabs_work_%s_%d", lang.name, w), onWork.View().Content)

failed := goldenModel(w, Options{})
next, _ := failed.fail(errors.New("gh: HTTP 403: rate limit exceeded"))
golden.Assert(t, fmt.Sprintf("app_error_%s_%d", lang.name, w), next.(Model).View().Content)
Expand Down
21 changes: 11 additions & 10 deletions internal/tui/app/mouse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,14 +104,15 @@ func TestClickingATabSwitchesToIt(t *testing.T) {
i18n.SetLanguage(lang)
m := newTestModel(Options{HasRepo: true})

x, _ := tokenAt(t, m, i18n.T("tab.repos"))
next, _ := m.Update(click(x, 0))
if next.(Model).tab != tabRepos {
t.Errorf("lang %s: clicking the Repos tab did not switch to it", lang)
// --repo starts on Repos, so Work is the one to click away to first.
next, _ := m.Update(click(0, 0))
if next.(Model).tab != tabWork {
t.Errorf("lang %s: clicking the Work tab did not switch to it", lang)
}
back, _ := next.(Model).Update(click(0, 0))
if back.(Model).tab != tabWork {
t.Errorf("lang %s: clicking the Work tab did not switch back", lang)
x, _ := tokenAt(t, m, i18n.T("tab.repos"))
back, _ := next.(Model).Update(click(x, 0))
if back.(Model).tab != tabRepos {
t.Errorf("lang %s: clicking the Repos tab did not switch back", lang)
}
}
}
Expand All @@ -123,7 +124,7 @@ func TestClickingTheGapBetweenTabsDoesNothing(t *testing.T) {
gap := ansi.StringWidth("1 " + i18n.T("tab.work"))

next, _ := m.Update(click(gap, 0))
if next.(Model).tab != tabWork {
if next.(Model).tab != m.tab {
t.Error("clicking between the tabs switched tabs")
}
}
Expand Down Expand Up @@ -159,7 +160,7 @@ func TestAClickIsNotBroadcast(t *testing.T) {
}},
prs: []gh.PR{{Number: 10, Title: "first pr"}, {Number: 11, Title: "second pr"}},
}
m := loadedApp(t, src, Options{HasRepo: true})
m := press(loadedApp(t, src, Options{HasRepo: true}), "1") // --repo lands on Repos

before, _ := m.repo.SelectedRef()
x, y := tokenAt(t, m, "another card")
Expand Down Expand Up @@ -228,7 +229,7 @@ func TestTheErrorScreenIgnoresTheMouse(t *testing.T) {
if cmd != nil {
t.Error("a click on the error screen produced a command")
}
if next.(Model).tab != tabWork {
if next.(Model).tab != m.tab {
t.Error("a click on the error screen switched tabs")
}
}
31 changes: 27 additions & 4 deletions internal/tui/app/scenario_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,9 @@ func scenarioModel(t *testing.T, f *scenarioSource) Model {
i18n.SetLanguage(language.English)
t.Cleanup(func() { i18n.SetLanguage(language.English) })

m := New(f, Options{HasRepo: true})
// No --repo: the repository is the working directory's, so the app starts
// on the board and the Repos tab appears when the lookup answers.
m := New(f, Options{})
next, cmd := m.Update(tea.WindowSizeMsg{Width: 120, Height: 40})
return resolve(t, next.(Model), cmd)
}
Expand All @@ -157,8 +159,16 @@ func TestClosingFromTheReposTabShowsTheNewState(t *testing.T) {
f := &scenarioSource{pr: scenarioPR()}
m := scenarioModel(t, f)

m = run(t, m, "2", "enter") // the Repos row -> the detail view
if !strings.Contains(content(m), "#12") {
m = run(t, m, "2")
// The Work board's own enter opens the same detail view, so without
// this the scenario would still pass with no Repos tab at all.
if m.tab != tabRepos {
t.Fatalf("precondition: tab = %d, want the Repos tab", m.tab)
}

m = run(t, m, "enter") // the Repos row -> the detail view
// The Repos row carries the number too; the state line is detail's alone.
if !strings.Contains(content(m), "state: open") {
t.Fatalf("the detail view did not open:\n%s", content(m))
}

Expand All @@ -176,7 +186,17 @@ func TestPickingALabelFromTheDetailViewAppliesIt(t *testing.T) {
f := &scenarioSource{pr: scenarioPR(), labels: []gh.Label{{Name: "bug", Color: "d73a4a"}}}
m := scenarioModel(t, f)

m = run(t, m, "2", "enter", "l", "space", "enter")
m = run(t, m, "2")
if m.tab != tabRepos {
t.Fatalf("precondition: tab = %d, want the Repos tab", m.tab)
}

m = run(t, m, "enter")
if !strings.Contains(content(m), "state: open") {
t.Fatalf("precondition: the detail view did not open:\n%s", content(m))
}

m = run(t, m, "l", "space", "enter")

if len(f.pr.Labels) != 1 || f.pr.Labels[0].Name != "bug" {
t.Fatalf("labels = %+v, want bug applied", f.pr.Labels)
Expand All @@ -200,6 +220,9 @@ func TestCommentingOnADiffLineFromTheWorkBoardShowsTheThread(t *testing.T) {
}},
}
m := scenarioModel(t, f)
if m.tab != tabWork {
t.Fatalf("precondition: tab = %d, want the board", m.tab)
}

// The board's own d opens the diff too, so enter is checked on its own.
m = run(t, m, "enter") // the card under the cursor -> the detail view
Expand Down
Loading