Skip to content

Remove timeline --watch - #646

Merged
jeremy merged 1 commit into
mainfrom
remove-timeline-watch
Aug 22, 2026
Merged

Remove timeline --watch#646
jeremy merged 1 commit into
mainfrom
remove-timeline-watch

Conversation

@jeremy

@jeremyjeremy commented Aug 22, 2026

Copy link
Copy Markdown
Member

What

Removes basecamp timeline --watch (and its -w shorthand and --interval
companion) along with everything reachable only from it: watchModel and its
bubbletea Init/Update/View, formatEvent, watchLabel,
runTimelineWatch, and the tests that covered only those two helpers.

This is a user-visible removal.--watch, -w and --interval now produce
an unknown-flag usage error. Both long flags are recorded in .surface-breaking
as acknowledged surface removals — release-scoped, since that file is cleared
after each release. Shorthands aren't recorded in .surface, so -w needs no
entry.

Why

runTimelineWatch reached tea.NewProgram with no interactivity check of any
kind
— no app.IsInteractive(), no IsMachineOutput(), no
stdinarg.InteractiveStdio(). The view set AltScreen = true, so
basecamp timeline --watch --json, --agent, or any piped invocation entered a
full-screen program that exited only on a key event or SIGINT.

It was the only reachable ungated bubbletea launcher in the repo. Every picker
path goes through resolve.Resolver.IsInteractive, the profile picker through
isInteractiveTTY, and basecamp tui is dev-build plus experimental-flag
gated. Worse, the shipped agent instructions pointed straight at it —
skills/basecamp/SKILL.md listed basecamp timeline --watch unqualified in
both the quick-reference table and the examples block, so an agent following the
skill hung.

Removing it rather than gating it: a live-polling TUI does not belong in a CLI
whose whole contract is one-shot machine-readable output. It can come back with
the actual TUI workspace, where it has somewhere to live. This also deletes the
last ungated launcher instead of adding a fourth thing that has to remember to
check.

Same family as #641/#645 — an agent invocation that hangs.

Testing

  • make check passes (via bin/ci) — including the surface snapshot vs
    .surface-breaking gate and skill drift on both skills

Smoke:

$ basecamp timeline --watch --json
{"ok": false, "error": "Unknown option: --watch", "code": "usage"} exit=1
$ basecamp timeline -w --json
{"ok": false, "error": "Unknown option: -w", "code": "usage"} exit=1

timeline --help no longer lists --watch/--interval and the Long help no
longer mentions polling. basecamp timeline --json is unaffected.

Audited every remaining tea.NewPrograminternal/commands/tui.go:115 (dev
build + experimental.tui), internal/tui/picker.go:505,527 (resolver-gated),
internal/tui/paginated_picker.go:393 and internal/tui/spinner.go:159 (both
dead code, zero callers). No ungated launcher remains.


Summary by cubic

Removes basecamp timeline --watch (and -w/--interval) to eliminate an ungated bubbletea TUI that entered alt-screen without interactivity checks and could hang non-interactive runs. These flags now return an unknown-option usage error; timeline remains a one-shot CLI.

  • Deletes watch-only code: runTimelineWatch, watchModel and its Init/Update/View, formatEvent, watchLabel, and their tests.
  • Records removals in .surface-breaking and drops flags from .surface; updates help and skills/basecamp/SKILL.md to remove watch references.
  • Audits remaining tea.NewProgram paths; all are gated via resolver or dev/experimental controls.
  • Migration: remove --watch, -w, and --interval from scripts and docs. Use basecamp timeline with --limit, --all, or --page as needed.

Written for commit 4dded08. Summary will update on new commits.

Review in cubic

`runTimelineWatch` reached `tea.NewProgram` with no interactivity check of any
kind — no `app.IsInteractive()`, no `IsMachineOutput()`, no
`stdinarg.InteractiveStdio()`. The view set `AltScreen = true`, so
`basecamp timeline --watch --json`, `--agent`, or any piped invocation entered a
full-screen program that exited only on a key event or SIGINT. It was the only
reachable ungated bubbletea launcher in the repo: every picker path goes through
`resolve.Resolver.IsInteractive`, the profile picker through `isInteractiveTTY`,
and `basecamp tui` is dev-build plus experimental-flag gated. The shipped agent
instructions pointed at it, so an agent following the skill hung.
Remove the feature rather than gate it. A live-polling TUI does not belong in a
CLI whose contract is one-shot machine-readable output; it can come back with
the actual TUI workspace, where it has somewhere to live. This also deletes the
last ungated bubbletea launcher instead of adding a fourth thing that must
remember to check.
This is a user-visible removal: `--watch`/`-w` and `--interval` are gone and now
produce an unknown-flag usage error. Both flags are recorded in
`.surface-breaking` as acknowledged surface removals (release-scoped; that file
is cleared after each release). `formatEvent`, `watchLabel`, `watchModel` and
their tests went with them — all were reachable only from watch mode.
CopilotAI balanced review requested due to automatic review settings August 22, 2026 07:39
@github-actionsgithub-actionsBot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills labels Aug 22, 2026

CopilotAI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Removes the unsafe, non-interactive timeline watch TUI while preserving one-shot timeline output.

Changes:

  • Removes --watch, -w, --interval, and watch-only code/tests.
  • Updates CLI surface snapshots and agent documentation.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 5 out of 5 changed files in this pull request and generated no comments.

Show a summary per file
FileDescription
.surfaceRemoves timeline watch flags from the public surface.
.surface-breakingAcknowledges the intentional breaking removals.
internal/commands/timeline.goRemoves watch flags, TUI implementation, and dependencies.
internal/commands/timeline_test.goDeletes watch-only tests.
skills/basecamp/SKILL.mdRemoves agent guidance referencing watch mode.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@jeremy
jeremy merged commit b6a9db6 into mainAug 22, 2026
25 checks passed
@jeremy
jeremy deleted the remove-timeline-watch branch August 22, 2026 07:49
jeremy added a commit that referenced this pull request Aug 23, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
jeremy added a commit that referenced this pull request Aug 23, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
jeremy added a commit that referenced this pull request Aug 23, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
jeremy added a commit that referenced this pull request Aug 23, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
jeremy added a commit that referenced this pull request Aug 23, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
jeremy added a commit that referenced this pull request Aug 23, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
jeremy added a commit that referenced this pull request Aug 23, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
jeremy added a commit that referenced this pull request Aug 23, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
jeremy added a commit that referenced this pull request Aug 24, 2026
`basecamp setup --json < /dev/null` hung forever. #646 removed
`timeline --watch` as "the only reachable tea.NewProgram without a gate",
but that claim came from `rg 'tea.NewProgram'`, and the instrument could
not see the larger family: internal/tui/forms.go wraps huh, and huh calls
tea.NewProgram inside its own package. Every tui.Confirm/Select/Input was
a bubbletea launcher the grep never showed, and `NewSetupCmd` reached one
with no interactivity check at all.
Two things make a reached prompt hang rather than fail. Bubble Tea does
not error on a non-terminal stdin — it opens /dev/tty and waits on the
real terminal (tea.go:590-613). And InteractiveStdio() tested
ModeCharDevice, so /dev/null passed: `cmd < /dev/null`, which is exactly
how an agent says it has nothing to type, was classified interactive.
So fix the instrument rather than adding another call-site check:
- InteractiveStdio() now asks term.IsTerminal, from the same
charmbracelet/x/term that Bubble Tea itself imports, so this floor and
the /dev/tty fallback it prevents cannot disagree about what a terminal
is. IsPiped keeps the character-device test; it answers a different
question. Adds InteractivePrompt() for stdin+stderr, because huh draws
forms to stderr while the picker draws to stdout — asking about the
wrong stream would let `cmd 2>somewhere` render an invisible question
that still blocks a terminal, and would refuse `cmd | less` where the
prompt would have worked fine.
- internal/tui grows a floor with one guarded runner per launcher: every
huh form goes through runForm, every bubbletea program through
runPicker. Both return a typed ErrNotInteractive. This covers prompts
nobody has written yet, which a call-site audit cannot — the audit is
what missed setup.
- `basecamp setup` refuses non-interactive stdio and machine-output modes
with a usage error naming what does work without a terminal. The gate
is on the parent's RunE only: `setup claude`, `setup codex` and
`setup agents` stay non-interactive and keep working. Refusing machine
output leaves the wizard's structured envelope unreachable, so that
branch, its two helpers and WizardResult's json tags go with it.
- `chat delete` refuses before any lookup when it would reach a
confirmation stdin cannot answer, naming --force. isNonInteractiveCommand
is left alone; it reads stdout, never stdin, and widening it would change
missingArg and noChanges across many commands.
Tests. A structural check that fails on a bubbletea launcher or a huh
import outside a short allowlist, and — inside an allowlisted file — on a
launch outside its named runner, so a second unguarded program cannot ride
in on the file's exemption.
Its import resolver is itself tested, because getting it wrong is silent.
Both bubbletea modules sit at a path ending in bubbletea (or bubbletea/v2)
and declare `package tea`, so path and package name disagree: deriving the
identifier from the path registers "bubbletea" while the file binds "tea",
and every unaliased launcher walks straight past. Nothing in the repo would
have noticed, since picker.go happens to alias its import. The resolver now
records each module's declared package name and honors an explicit alias
over it, with all four spellings pinned by test.
Also: a table over every exported prompt, AST-checked for completeness,
that fails if a new prompt skips the floor. A predicate matrix whose
terminal/terminal row is the baseline that keeps the negative rows from
holding vacuously. And e2e cases that put a real pty on stdout with
/dev/null on stdin, the only shape that reproduces the original hang.
Tests that used /dev/null as a terminal stand-in now open /dev/ptmx, and
each floor test points the launcher's own output stream at a pty so stdin
is the variable actually under test.
@jeremyjeremy added the breaking Breaking change label Aug 31, 2026
@jeremyjeremy removed the breaking Breaking change label Aug 31, 2026
@robzolkosrobzolkos added the breaking Breaking change label Sep 3, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breakingBreaking changecommandsCLI command implementationsskillsAgent skillstestsTests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants

@jeremy@robzolkos