Skip to content

feat(tui): run goals in Terminal Studio - #61

Merged
saikrishna321 merged 6 commits into
appclawhq:mainfrom
Delta456:feat/terminal-studio-goal-mode
Aug 31, 2026
Merged

feat(tui): run goals in Terminal Studio#61
saikrishna321 merged 6 commits into
appclawhq:mainfrom
Delta456:feat/terminal-studio-goal-mode

Conversation

@Delta456

@Delta456 Delta456 commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Bare appclaw and appclaw "a goal" now open Terminal Studio in a new goal mode, instead of the readline prompt plus single-screen Ink run. Same shell as --tui, same device session, same palette and history — only what a plain (non-slash) line means differs: an instruction to record, or a goal to run.

That change pulled in four others: the goal pipeline had to come out of index.ts before two callers could share it; the prompt had to stop eating keyboard shortcuts before goal mode could have any; a finished run had to leave its summary somewhere reachable; and a bad .env had to stop killing the process before any of it could be reported on screen.


Goal mode

image image

store.mode is 'record' | 'goal'. --tui opens in record (a plain line is one deterministic instruction, appended to the recording); bare appclaw opens in goal (a plain line is a goal, run through the full planner and drawn on a new run screen beside the device stream). /mode switches without dropping the session.

  • packages/cli/src/goal-session.ts (new) — decomposeGoal → per-sub-goal orchestration → runAgent → journey summary. MCP, LLM and AppResolver are dependencies rather than things it creates, so the one-shot CLI and the resident shell drive the same pipeline. It deliberately does not own device setup, Ink mounting, --export writing or process exit — those differ between a one-shot run and a resident shell. index.ts is 429 lines lighter.
  • attachRunRenderer() — two Ink apps cannot share stdout, so goal mode never calls activateInk(); it borrows only the renderer seam and mounts RunScreen as one of its own screens.
  • AgentOptions.signal — cooperative cancellation, checked at each step boundary. A stop lands after the action in flight finishes, which is why the screen says "stopping…" rather than implying the keypress was instant.
  • appclaw "goal" holds the finished screen until a key is pressed before exiting 0/1. Leaving the alternate screen erases the summary and the last device frame, which is the whole point of having watched. APPCLAW_TUI=off keeps the unattended path.

The palette is hidden in goal mode, not filtered

A plain line there is a goal, so a standing command list is a menu for nothing — the transcript gets its rows instead. The column's total height is unchanged, which is what keeps the stream panel where the frame loop expects it. The list reappears as soon as the line starts with /, where it is a live filter, and a wrapped prompt then borrows its extra rows from the transcript. layout.ts owns those numbers (paletteRows, transcriptRows, inputLineBudget, inputLineCount) so MainScreen and CommandPalette cannot disagree about how tall the prompt is.

Stream shortcuts, and the input rewrite they needed

^r starts or resumes the stream, ^p pauses/resumes, ^x closes it — bound on both the goal prompt and the run screen by tui/stream-keys.ts, which holds the chord table, the handler and the status-bar hints together so a binding cannot outlive its advertisement. Everything the stream says about itself now names a chord rather than a slash command, because goal mode hides the palette.

They are ctrl chords because the goal prompt is a focused text input, where a bare p is part of the goal being typed. That is why components/PromptInput.tsx replaces ink-text-input: that component inserts any ctrl chord it does not recognise as a plain letter (^p types p), so no screen holding a focused prompt could own a shortcut. PromptInput ignores every ctrl/meta chord (ctrl+c included, so TuiApp still quits), leaves arrows/tab/page keys to the screen, and snaps the cursor to the end when the value is replaced from outside — which also fixes history recall and tab completion landing the next character mid-string.

StreamStatus gained paused, genuinely distinct from idle: closing stops the loop, removes the temp dir and deletes the transmitted kitty image, whereas pausing only gates capture and leaves the frame on screen for a free resume.

/export and the journey summary survive a run

The run screen was the only place the journey summary was drawn, so leaving it took the sub-goal breakdown, tokens and cost with it — re-running the agent was the only way to see any of it again. summariseOutcome() (tui/goal-summary.ts) writes them into the transcript as the result entry's detail block, sub-goal results included, since what the agent found is the reason the run happened.

/export now works in both modes, from different material: the recorded step list in record mode, the last goal run in goal mode, through the same writer appclaw "goal" --export uses. The resident shell is where you iterate on a goal until it does the right thing, which is exactly when you want it frozen into a deterministic spec. The run's platform is captured with the outcome rather than read at export time, so a /device switch in between cannot write an iOS spec for an Android run.

A rejected .env value no longer crashes with a stack trace

Config is built at import time, so a throw there escaped every try/catch in the CLI and Node printed a raw ZodError and a stack trace because someone typed LLM_PROVIDER=claude.

safeLoadConfig() reports instead of throwing, dropping only the offending keys so their schema defaults apply and the rest of the environment survives — enough to say what is wrong, never enough to run on. loadConfig() still throws for the SDK, now with a readable message.

Who reports it is decided in one place, opensTerminalStudio(cliArgs): the shell can rewrite .env, so it gets the problem on SetupScreen and can fix it in place; every other path takes printError + exit 1 rather than running on defaults the user did not choose. Settings shows a rejected key's raw .env value rather than the default it fell back to (the typo is the thing being corrected) and appends any offending key not already listed, so "press enter to edit it here" is never a dead end.

$ LLM_PROVIDER=claude AGENT_MODE=hybrid APPCLAW_TUI=off appclaw --flow x.yaml
  ✗ Invalid configuration
    LLM_PROVIDER: Invalid enum value. Expected 'anthropic' | 'openai' | 'gemini' | 'groq' | 'ollama', received 'claude'
    AGENT_MODE: Invalid enum value. Expected 'dom' | 'vision', received 'hybrid'

Smaller fixes

  • StepLine cells got flexShrink={0} + wrap="truncate" — in a narrow column Yoga reflowed the fixed-width grid onto a second row ([1/3 over 0]), scrambling the row and costing the caller a row its height budget never reserved.
  • RunScreen sizes from RunWidthContext rather than stdout.columns, which are the same thing only when the run owns the whole terminal; in a pane the summary box drew wider than its column and overflow: hidden sliced its border off.
  • StatusBar breadcrumb no longer clipped to "Goa" at 100–118 columns.
  • Wordmark extracted from WelcomeScreen.

Docs

README.md, CLAUDE.md, .agents/skills/use-appclaw-cli/SKILL.md and landing/public/usage.html updated for goal mode, the chords, /export and the config behaviour.


🤖 Generated with Claude Code

https://claude.ai/code/session_0163wRmYBwSsV8xUDmuiEZio

Delta456 and others added 6 commits August 31, 2026 13:29
`Config` is built at import time, so a `throw` there escaped every
try/catch in the CLI and Node printed a raw ZodError with a stack trace
because someone typed `LLM_PROVIDER=claude`.

`safeLoadConfig()` reports instead of throwing, dropping only the
offending keys so their schema defaults apply and the rest of the
environment survives — enough to say what is wrong, never enough to run
on. `loadConfig()` still throws for the SDK, now with a readable message.

`collectSetupIssues()` turns those into something SetupScreen can show
and offer to fix, since the shell is the one caller that can rewrite
`.env` rather than only complain about it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163wRmYBwSsV8xUDmuiEZio
Bare `appclaw` and `appclaw "a goal"` now open the same multi-screen
shell as `--tui`, in a new goal mode, instead of a readline prompt plus a
single-screen Ink run. `store.mode` decides what a plain (non-slash) line
means: one deterministic instruction to record, or one goal to run.

`goal-session.ts` is the pipeline both callers share — decompose,
orchestrate sub-goals, run the agent, summarise. MCP, LLM and AppResolver
are dependencies rather than things it creates, and it deliberately does
not own device setup, Ink mounting, `--export` writing or process exit,
since those differ between a one-shot run and a resident shell.

Two Ink apps cannot share stdout, so goal mode never calls
`activateInk()`; `attachRunRenderer()` lends it the renderer seam and it
mounts RunScreen as one of its own screens.

`AgentOptions.signal` stops a run cooperatively, checked at each step
boundary — a stop lands after the action in flight finishes, which is why
the screen says "stopping…" rather than implying the keypress was
instant.

`appclaw "goal"` holds the finished screen until a key is pressed before
exiting 0/1: leaving the alternate screen erases the summary and the last
device frame, which is the whole point of having watched. `APPCLAW_TUI=off`
and any non-TTY keep the unattended path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163wRmYBwSsV8xUDmuiEZio
A plain line in goal mode is a goal, so a standing command list is a menu
for nothing — the transcript takes its rows instead. The column's total
height is unchanged, which is what keeps the stream panel where the frame
loop expects it. The list reappears the moment the line starts with `/`.
`layout.ts` owns those numbers so MainScreen and CommandPalette cannot
disagree about how tall the prompt is.

`^r` starts or resumes the stream, `^p` pauses/resumes, `^x` closes it —
bound on both the goal prompt and the run screen by `stream-keys.ts`,
which holds the chord table, the handler and the status-bar hints
together so a binding cannot outlive its advertisement.

They are ctrl chords because the goal prompt is a focused text input,
where a bare `p` is part of the goal being typed. That is why
`PromptInput` replaces `ink-text-input`, which inserts any ctrl chord it
does not recognise as a plain letter — `^p` typed "p" and no screen
holding a focused prompt could own a shortcut. It also snaps the cursor
to the end when the value is replaced from outside, fixing history recall
and tab completion landing the next character mid-string.

StepLine's cells get `flexShrink={0}` and `wrap="truncate"`, and RunScreen
sizes from `RunWidthContext` rather than `stdout.columns`: in a column
beside the stream, Yoga reflowed the fixed-width grid onto a second row
and the summary box drew wider than the pane that clipped it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163wRmYBwSsV8xUDmuiEZio
The run screen was the only place the journey summary was drawn, so
leaving it took the sub-goal breakdown, tokens and cost with it — the
shell came back to the prompt holding one line, and re-running the agent
was the only way to see any of it again. `summariseOutcome()` writes them
into the transcript as the result entry's detail block, sub-goal results
included, since what the agent found is the reason the run happened.

`/export` now works in both modes, from different material: the recorded
step list in record mode, the last goal run in goal mode, through the same
writer `appclaw "goal" --export` uses. The resident shell is where you
iterate on a goal until it does the right thing, which is exactly when you
want it frozen into a deterministic spec.

The run's platform is captured with the outcome rather than read at export
time, so a `/device` switch in between cannot write an iOS spec for an
Android run.

Note: the `/export` dispatch and the transcript wiring live in
`tui/commands.ts` and `tui/index.ts`, which landed with the goal-mode
commit that rewrote them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163wRmYBwSsV8xUDmuiEZio
`Wordmark` comes out of WelcomeScreen so both entry screens draw the same
banner and its width fallback lives in one place.

The status bar clipped its breadcrumb to "Goa" between 100 and 118
columns; the breadcrumb no longer shrinks, so the hints truncate instead.

Docs updated across README, CLAUDE.md, the CLI skill and the landing
page's usage guide for goal mode, the stream chords, `/export` and the
config behaviour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163wRmYBwSsV8xUDmuiEZio
The previous fixture was a structurally valid Google API key. Whatever
its provenance, a credential-shaped string in a test trips secret
scanners and invites the next person to paste a real one in the same
slot. The replacement still exercises the masking — long enough to be
truncated, with four distinguishable trailing characters.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0163wRmYBwSsV8xUDmuiEZio
@saikrishna321
saikrishna321 merged commit fa717e9 into appclawhq:main Aug 31, 2026
7 checks passed
github-actions Bot pushed a commit that referenced this pull request Aug 31, 2026
## [2.6.0](v2.5.0...v2.6.0) (2026-08-31)

### Features

* **tui:** run goals in Terminal Studio ([#61](#61)) ([fa717e9](fa717e9))
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 2.6.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

@Delta456
Delta456 deleted the feat/terminal-studio-goal-mode branch August 31, 2026 10:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants