Slash commands returned from _on_enter before _expand ran, so a command
argument carried the literal [Pasted text #N] / [File #N] / [Image #N]
placeholder instead of the real content — e.g. `/morphling <pasted
multi-line target>` reached the agent as the marker string, not the paste.
Expand placeholders at the top of the command branch (mirroring the normal
submit path) and clear the placeholder stores afterward. No-op for commands
without placeholders.
Ctrl+C / selection copy fell back to Textual's OSC 52 escape on Windows/Linux.
Some terminals (notably VSCode's integrated terminal) decode the OSC 52 base64
payload as latin-1 instead of UTF-8, so non-ASCII copied text lands in the
clipboard as mojibake (e.g. 需要 -> éè¦). Classic Windows conhost ignores
OSC 52 clipboard writes entirely, so copy was a silent no-op there (incl.
Windows Server).
Prefer the OS-native clipboard (_copy_to_clipboard: win32 CF_UNICODETEXT /
pbcopy / wl-copy / xclip), which writes real Unicode and bypasses the terminal.
Keep OSC 52 only as a fallback, and force it under SSH (SSH_CONNECTION /
SSH_TTY) where the native call would write the remote host's clipboard instead
of the user's local one.
- _term_write(): route OSC/control sequences through Textual's driver writer
queue instead of direct sys.__stdout__. A title/OSC write landing mid-frame
during streaming used to split Textual's own escape sequences, rendering as
flashing ANSI garbage; serializing on the same writer queue lands it cleanly.
- sidebar: wrap the session list in a VerticalScroll (#sidebar-scroll) so it
scrolls when overflowing (scrollbar-gutter reserved to avoid row reflow);
inner #sidebar Static keeps padding so the click hit-test math is unchanged.
Keyboard session-switch scrolls the active session into view.
Rename tracked email.py/lark.py to _email_example.py/_lark_example.py so
the poller (which skips _-prefixed files) no longer polls them on a fresh
clone. Activate by copying to email.py/lark.py (now gitignored). Also nudge
manager to reuse existing subagents on IM signals.
/continue list previews showed JSON debris (e.g. `{`, `" } ]} === Response ===`)
for some sessions. Two causes in _preview_from_file:
1. Models sometimes emit an unclosed <summary>, so the non-greedy DOTALL regex
pairs it with a far-away </summary> and captures === block headers / JSON
across rounds. Now only the latest <summary> is used, and it is rejected if
it looks like such a cross-segment capture (contains `=== `/`"role"` or
>200 chars) — falling through instead of digging for older summaries.
2. The fallback scanned for the first non-=== line, returning JSON structure
lines. It now uses the last real user prompt via a new _last_user(), which
scans Prompt blocks directly (so response-less/aborted sessions still
preview) and reuses _user_text() to skip tool_result continuations and all
_INJECT_MARKERS (WORKING MEMORY / SYSTEM TIPS / DANGER ...).
Scanned 261 local logs: garbage previews 26 -> 0.
* fix(tui): stop CRLF stdout rendering as blank lines (v2)
Windows child stdout is `\r\n`; the streaming path fed it straight to
`Text.from_ansi`, which treats `\r` as a carriage return and erases each
line's text, so a `[Stdout]` block rendered as a run of blank lines until
the turn finished (the done-state Markdown render strips `\r`). Normalise
`\r\n` -> `\n` before `from_ansi`; lone `\r` is kept so progress-bar
overwrites still work.
* fix(tui): stop leaking empty per-session task_dir signal folders
Both v2 and v3 eagerly `os.makedirs` the per-session `_intervene` /
`_keyinfo` signal dir, but the only writer (`_intervene`) already creates
it lazily — so every session that never used intervene left a stale empty
`temp/_tui_v2_<pid>_<id>` / `temp/_tui_v3_<pid>` behind (85+ accumulated).
`consume_file` tolerates a missing dir, so the eager create is redundant.
- Set only the task_dir *path*; let the writer create it on first inject.
- Remove empty dirs on session close (v2 `_cmd_close`), on graceful exit
(v2 `on_unmount`, v3 `_cleanup`), and sweep stale ones left by crashes
at startup (v2 `on_mount`, v3 `main`). `os.rmdir` only removes empty
dirs, so a still-pending `_intervene` is never clobbered.
* fix(tui): repair /continue search box cursor & up-nav loop (v2)
The SearchablePicker swallowed Right while the search Input had focus
(always treating it as "select"), so the caret could never move right in
the query. And Up could leave the list but never return: from the search
box Down entered the list, yet Up at the top row was stuck.
- Right commits the highlight only when the caret is already at end of the
query; otherwise it moves the caret within the search text.
- Up on the first list row returns focus to the search box (scoped to
/continue — no-op for ChoiceLists outside a SearchablePicker), clearing
the highlight so the next Down re-enters at the first row, not the second.
- Up from the search box wraps around to the bottom of the list.
Root cause of the lingering Ctrl+S freeze even when the session is
idle: `InputArea.reset()` calls `self.text = ""`, which goes through
`load_text("")` → `_set_document("")` (textual/widgets/_text_area.py
:1181-1186). That rebuilds Document + WrappedDocument from scratch
and then `_refresh_size()` (line 1287) reactively retriggers a full
layout pass over the entire DOM. On a long session (100+ message
widgets in the scroll), the layout cascade blocks the UI for seconds.
Switch to `TextArea.clear()` which goes through the edit pipeline:
`delete((0,0), document.end)` → `wrapped_document.wrap_range(...)` —
no Document rebuild, range-scoped wrap only. Guarded with
`if self.document.text:` so a clear() on an already-empty buffer is a
no-op (avoids posting a spurious Changed event).
Secondary fix: `_stash_cleanup_clear` previously wrapped reset() in
`try/finally` that cleared `_skip_change_next = False` immediately.
The Changed event posted by reset() is async-queued, so by the time
the handler ran the flag was already False — the short-circuit was
useless and on_text_area_changed went through the heavy resize +
palette path twice (once in the handler, once explicitly). Drop the
try/finally; let on_text_area_changed itself clear the flag at line
3256-3258 when the deferred Changed event finally lands.
Also scrubs external TUI-project names from comments per request.
Re-research revealed both mainstream TUIs DO mid-turn inject — my
previous read of "Codex/CC queue post-turn only" was wrong.
Codex (codex-rs/core/src/session/mod.rs:2986): `steer_input` → mailbox
`push_pending_input` → `session/turn.rs:391 get_pending_input` →
`record_pending_input` appends as a regular user message BEFORE the
next sampling. No special prefix — the model sees the steer in
conversation history alongside the original task, as a follow-up.
Claude Code (utils/messages.ts:5510): drains queue after each tool
iteration and wraps the user's text:
The user sent a new message while you were working:
{raw}
IMPORTANT: After completing your current task, you MUST address
the user's message above. Do not ignore it.
The actual bug in model_responses_042511.txt was the bare `[MASTER]
{raw}` framing reading as a directive override, not the mid-turn
injection itself.
This commit:
- Wraps queued text with the Claude-Code phrasing before writing
to `_intervene` (i18n: en + zh).
- Restores `_intervene_pending` tracking + exit-replay hook so an
agent that hits exit_reason between user submit and turn end
doesn't lose the message.
- Adds `inject_intervene(text, track=True)` opt-in tracking so
`!cmd` shell facts (which are factual context, not user input)
don't accidentally end up in the user-message replay queue.
- v2 mirrors with `_INTERVENE_WRAP_EN/_ZH` constants picked via
`$GA_LANG`, `sess.pending_wrapped` parallel to `sess.pending`,
and `_install_intervene_replay_hook` per-session.
- Up-pop recall stays removed (file write is immediate, popping
would leave a stale duplicate).
Refs: codex-rs/core/src/session/mod.rs:2986; claude-code/src/utils/messages.ts:5496
Evidence: model_responses_042511.txt — original task was "web_scan上游PR";
mid-turn `[MASTER] 请你立即输出一个hello` was prepended to next_prompt,
model dropped the original task and just printed hello. Codex
(`maybe_send_next_queued_input`, chatwidget.rs:6773) and Claude Code
(`useQueueProcessor`, src/hooks/useQueueProcessor.ts) both queue user
input and drain ONLY when the agent goes idle — never mid-turn.
What changed:
- AgentBridge no longer tracks `_intervene_pending`; turn_end hook
reverts to just ask_user extraction.
- Submit while running appends to local `_pending`, does NOT write to
`_intervene`.
- v3 watches `is_running` True→False in maintenance loop; on transition
drains via `_submit(combined, [])` as a fresh task.
- v2 drains in `_on_stream(done=True)` right after status flips to
"idle"; reuses `submit_user_message` to spawn the next task.
- v2 dead helpers (`_inject_intervene`, `_session_intervene_path`,
`_install_intervene_replay_hook`) removed.
- `inject_intervene` retained on v3 bridge for `!cmd` shell history
only — factual context (code-block output), low risk of confusing
the model.
Net: -109 lines. Esc still cancels the queue. Ctrl+C still aborts;
since abort flips status to idle, queued messages drain as the next
task — matches Claude Code's "Ctrl+C then queue runs" behavior.
Refs: openai/codex/codex-rs/tui/src/chatwidget.rs:6773;
anthropics/claude-code/src/hooks/useQueueProcessor.ts
`subprocess.run(cmd, shell=True)` picked the OS default — cmd.exe on
Windows. `!ls` failed with "not recognized" on stock Windows; encoding
defaulted to the active codepage so utf-8 output mojibaked.
Detect once via $SHELL (works on Git Bash, WSL, MSYS2, every Unix);
on bare Windows fall through Git Bash → bash on PATH → pwsh →
powershell → cmd. Matches Claude Code / Codex CLI behaviour where
`!cmd` inherits the launching shell.
Display name surfaces in the agent's history line: `[!shell bash]` /
`[!shell pwsh]` so a follow-up like "rerun that with -v" can pick the
right syntax.
Refs: code.claude.com docs (bash mode); developers.openai.com/codex/cli/features (Tab/! prefix)
Previous attempts used a rolling/hard-cap timer to gate the file write
to `_intervene`. Both leaked under the same failure mode: when the
agent reached its final turn before drain time, `consume_file` ate the
content but `agent_loop` discarded next_prompt on exit, so the queued
text silently vanished.
New design follows Codex CLI / Claude Code style — delivery is anchored
to the agent's own turn boundaries instead of a wall clock:
Submit while running → append `_intervene` immediately + add to UI list
Turn end hook fires →
exit_reason set → consume_file ate it, next_prompt discarded.
Re-route the combined text via `agent.put_task`
so the outer `while True: task_queue.get()` loop
picks it up as the next user turn.
not exit → content was prepended to next_prompt as `[MASTER]
...` — just clear the UI mirror.
Esc → `clear_intervene` deletes the file + clears the list.
No timers, no cooldowns. The Up-pop "amend" path is gone because
immediate file writes make in-composer edits append a duplicate.
Threading: v3 bridge protects `_intervene_pending` with `_intervene_lk`;
v2 adds `pending_lk` on AgentSession. v2's hook closure uses
`call_from_thread` for the UI refresh hop.
Right-click in v2 (`InputArea._on_mouse_down`) now short-circuits
button==3 before TextArea's default cursor-move runs, so paste lands
at the existing caret rather than where the mouse happened to be.
Refs: openai/codex#15842, anthropics/claude-code#44851
`app.run(handle_sigint=False)` lets a stray SIGINT surface as
KeyboardInterrupt through asyncio.runners — catch it in run() so the
shell sees a clean exit. Also drop the literal `'session'` fallback in
`_term_title`; render just `GenericAgent` until a session is named.
`tuiapp_v2.py:27` imports `fmt_key`, `fmt_keys` from `keysym`, but the
module file was never tracked. PR #513 (tui v2/v3 fixes) is unusable on a
fresh checkout without this. Adds the cross-platform shortcut formatter
that turns binding strings into mac/Win-Linux key labels (e.g.
`"ctrl+b"` → `"⌃B"` / `"Ctrl+B"`), honoring `GA_KEYSYM_STYLE`.
Last commit wrapped both the `! cmd` echo AND the `└ output` rows in
the charcoal tile. User wants only the echo line carry the band —
output rows stay on the terminal's native bg so the eye reads "this
is the command, that's its output" instead of one fused block.
Previous commit's _TILE_SHELL forced a light fg across both the `└`
gutter and the command output, erasing the dim styling on the gutter
and overwriting any ANSI the shell command emitted into stdout.
Make the tile bg-only (`\x1b[48;2;65;60;65m`). Re-add `_DIM` around the
`└ ` gutter and leave `ln` un-styled so the terminal's default fg /
the shell's own colours come through. The band itself stays full-row
because `_tile` re-asserts the bg after every embedded `_RST`.
Per user screenshot 040748, the committed `! echo hi` line and the
`└ output` rows used to sit on bare black while normal user bubbles
got the 55,55,55 charcoal band — visually inconsistent and broke the
cc-style "this is a discrete block" affordance.
New `_TILE_SHELL` (65,60,65 bg, light fg) wraps both halves via
`_tile()`, so the pink `!` prompt and dim `└` glyph inherit the band
across the full row width. Slightly warmer shade than `_TILE_U` so
the user-bubble and shell-output bands stay distinguishable when
interleaved. Black-terminal only — matches user spec.
v2 Ctrl+S freeze: defer reset() + cleanup via call_after_refresh so the
keystroke handler returns immediately even when streaming has the
reactive queue saturated. Added _skip_change_next flag to short-circuit
on_input_area_changed during the deferred clear.
v3 terminal title: route _set_term_title via os.write(1,...) — sys.stdout
gets redirected to sb_agent.log during PTK app run, so OSC 0 escapes
were silently ending up in the log file. Default _session_name = 'session'
for v2 parity, and repaint title on running→idle transition.
v3 input row bg fill: _tile() now pads explicit bg-active spaces to
width — PTK's cell renderer doesn't honour \x1b[K (erase-to-EOL), so
the user bubble's charcoal band used to stop at text length.
v3 /emoji picker: arrow-key menu (mirrors /llm). Pet styles cleaned to
bear (default) + cat + dot + unicode. Bear calm tier restored to ʕ•ᴥ•ʔ.
• used for the calm tier, o for focused — matches user-requested mood
progression. Pet frame rate decoupled from spinner via self._spin // 5.
v3 ask_user "coderun freeze" fix: _tool_status no longer marks an
in-flight ask_user chip as ✓ ok when the stream contains the
"Waiting for your answer ..." marker. Bumped DoneEvent grace from 2 s
to 10 s when the marker is in the stream so a slow ask_user_queue.put
doesn't cause the picker to fall through to _finalize.
v3 /scheduler picker: added pre_checked kwarg to _show_menu so the
running set lands atomically. slash_cmds._match_service relaxed to
path-only matching so directly-launched (`python reflect/foo.py`)
reflect tasks are detected. Running rows highlighted in functional
green. Confirm card accepts ← as cancel (parity with Esc) — same on
v2 ChoiceList.
v3 keybindings: Esc Esc (within 800 ms) → /rewind (handled in
_handle_key so PTK's Esc interception path actually fires).
v3 pending input queue: agent.task_dir wired so ga.turn_end_callback
can consume `<task_dir>/_intervene`. New AgentBridge.inject_intervene
appends to the file (atomic vs read-modify-write), and _drain_pending
routes the combined message either through that hook (mid-turn) or via
put_task (idle). Cooldown 5 s, resets per new pending. ↑ recalls the
last queued message, Esc clears the queue. v2 has equivalent shape:
AgentSession.pending + per-session agent.task_dir + _inject_intervene
+ _poll_pending timer.
! shell magic: prefix `!` enters shell mode — pink border, prompt mark
swaps `❯` → `!`, body strips the leading `!` so the glyph appears
once. Enter runs the rest as `subprocess.run(shell=True, timeout=30)`,
echoes `! cmd` + `└ output` into scrollback, and appends to LLM
history (via _intervene when agent is running, direct backend.history
append when idle — sidesteps the iterator race).
/resume: forwarded as a literal to the agent so GA's _handle_slash_cmd
(agentmain.py:124) can expand it. Added to v3 _cmds() + v2 COMMANDS +
slash_cmds.COMMANDS + i18n + /help + Tips.
Turn marker regex fix: agent_loop.py:52 switches `**LLM Running
(Turn N) ...**` to the short `**Turn N ...**` when task_dir is set.
Extended all v3/v2 turn-marker regexes to match either form.
Cherry-picked upstream fixes:
- PR #466 (8ae3645): _collapse_choice early-return when the
on_select callback (e.g. /rewind) detaches the anchor widget.
- PR #461 (08f21e8): SelectableStatic.has_valid_selection_parent +
app-level _is_stale_selectable_mouse_event filter that swallows
mouse events on detached widgets.
1. _TILE_U: switch tile bg/fg to truecolor (48;2;55;55;55 / 38;2;230;230;230)
so the file-tree tiles render legibly on Win Terminal dark themes
instead of a muddy 8-color block.
2. _ticker: tighten the redraw cadence 0.4s -> 0.1s so the spinner and
status line feel responsive on Windows.
3. terminal title: add _set_term_title() helper and emit OSC 0 on init /
session new / rename / quit so the tab title tracks the active session.
4. _drain: stream-aware grace window — when the pending stream carries a
pending ask_user ("Waiting for your answer"), extend the post-done poll
so the question card is not swallowed before the prompt appears.
5. /emoji command: split _PETS into _PETS_UNICODE / _PETS_ASCII behind a
module-level _pet_style toggle; add the /emoji dispatch + _cmd_emoji
handler so users on fonts without emoji can fall back to ASCII pets.
Local-only edits, no cross-module API change; AST parses clean.
Co-authored-by: Shen Hao <shenhao-stu@users.noreply.github.com>
/continue previously pushed plan_scan_baseline past every restored
message, so plan_state.is_active() saw an empty slice and hid the card —
even when the prior session's plan.md still had `[ ]` items the user
wanted to keep working on. `working['in_plan_mode']` isn't replayed by
the log restore either, so the card never came back.
Make the baseline conditional: default to 0 so the scanner walks the
full restored history; only push past it when the restored plan.md is
already all-done, suppressing the stale ✓ card. Resolved-path missing
or empty items naturally fall back to "no card".
/stop now also refills the input box with the just-sent user message,
so an interrupted send is one keystroke from edit-and-resend. Only
when the box is empty (don't clobber a half-typed follow-up). Agent
history is untouched — a resend duplicates the turn in LLM context;
/rewind 1 is the manual escape.
Behaviour matrix (/continue):
unfinished plan → baseline=0, scanner finds path, card resumes
completed plan → baseline=len(msgs), card stays hidden
plan.md deleted → resolve_path None, card stays hidden
fresh enter_plan_mode after continue → working flag re-arms, normal
* fix(tui): slash-cmd polish — /scheduler perf, v2 i18n typo, v3 bundle i18n
- slash_cmds: cache `running_services` list (≥1s TTL) and use a single
service-name preload + lru-cached lookup. /scheduler picker cold path
~2.1s → ~1.0s, warm path ~0ms. Cache is invalidated whenever
`start_service` succeeds so the picker reflects the new state on the
next open.
- tuiapp_v2: drop the stray space inside `/reload-keys` zh description
("重新加载 mykey.py" → "重新加载mykey.py").
- tui_v3: route the remaining slash_cmds-bundle rows (/autorun,
/morphling, /goal, /hive, /scheduler) through _t() the same way
/update already was, with matching en/zh entries in _I18N. Keeps the
v3 command palette fully bilingual.
* tui: settle spinner on abort + DRY-extract annotation helpers
Aborting a turn (Ctrl+C / /stop) left the live spinner ticking elapsed
until the LLM stream unwound in the background. Now we freeze a
`_stop_summary` snapshot the instant the user aborts and flip the line to
a settled "Stopping…", then "Stopped after Xm Ys" once done.
- ChatMessage._stop_summary: abort-time (elapsed, in, out) snapshot
- _mark_stopping(): freeze + repaint every in-flight assistant on /stop
- _stopping_annotation() / _done_annotation() stopped-state branch
- DRY: _fmt_tokens() single source for `↑ N · ↓ M`, _freeze_summary()
single source for the snapshot tuple (de-dups spinner/done/stopping
and the two capture paths)
- carries the branch's existing ask_user multi-choice "; ".join collapse
---------
Co-authored-by: Shen Hao <shenhao-stu@users.noreply.github.com>
The previous /update prompt always did `git pull` + auto-merge and kept
local edits without committing, which left users with upstream changes
sitting as staged-only changes and an unintended divergent local branch.
Rework the /update prompt (both zh/en) into two explicit paths:
- If local commits exist: merge upstream into the current branch,
auto-resolving conflicts in favor of upstream, keeping local commits.
- Otherwise: `git reset --mixed upstream/main` so local main aligns
exactly with upstream/main, leaving no local-only commit while
uncommitted working-tree changes are preserved.
Never push; never create a commit in the no-local-commit path. Also adds
a concise preview + summary step. Prompt kept short per user request.
Before: /scheduler picker always showed an empty selection, so reflect/*.py
and frontends/*app*.py services that were already running could neither be
identified nor stopped from the TUI. Re-running /scheduler started another
instance instead of toggling the existing one.
This change keeps discovery in the shared slash_cmds module and wires both
TUIs through the same helpers (parity rule from tui_frontend_sop):
* frontends/slash_cmds.py
- running_service_ids(): inspect hub.pyw subprocess registry and return the
set of currently-running service ids (reflect/* and frontends/*app*).
- stop_service(sid): graceful terminate via the same registry, idempotent.
- list_launchable_services() result is unchanged; callers read running set
separately so the picker can decorate labels without leaking state into
discovery.
* frontends/tuiapp_v2.py, frontends/tui_v3.py
- Picker now pre-checks the running set and appends an i18n " · running"
tag to those labels so the user can SEE what is up and untick to stop.
- On submit we diff picked vs running -> (starts, stops); empty diff is a
no-op with a clear breadcrumb. Confirm card lists both sides.
- MultiChoiceList (v2) no longer early-returns on empty labels for picker
callbacks, so unticking the last running service still fires on_select
(single-select dialogs retain the legacy guard).
- Full bilingual i18n: scheduler.running_tag / no_change / diff.start /
diff.stop added to both en/zh tables.
Smoke: py_compile clean on all three files; manual /scheduler round-trip
(start A, reopen picker -> A pre-checked, untick A -> stop A) verified on
both v2 and v3.