In-place /continue retargets agent.log_path to the restored file, so the
reset bind `_bind_workspace(None)` ran during restore was persisting
session_ws_set(path, "") — erasing that session's own workspace mapping to
"" (read back as "explicitly off") before we read it, which also short-
circuited the log-scan fallback. The continued session never re-entered its
workspace.
Add a `persist` flag to `_bind_workspace`; the restore-time reset passes
persist=False so it only refreshes in-memory state. The session→workspace
map is now written solely by explicit /workspace, /workspace off, and a
successful restore.
ESC cancel sent session/cancel RPC but never cleared the busy flag, leaving the UI stuck if the server-side cancelled event never arrived. Also clear busy on all sessions when the bridge closes so pending poll loops exit cleanly.
Co-authored-by: dilong888 <dilong888@users.noreply.github.com>
The tool-cards change (#593) tightened _md_line_has_box_drawing to exempt
pure-horizontal '─' runs as markdown hr. But a SIMPLE-box Markdown table's
only box glyph is its header rule, so that exemption dropped whole tables
out of the passthrough copy path and into the wide-render aligner, which
maps selection cell-columns to character indices — misaligning copy of any
table with CJK (wide) cells.
Remove the pure-horizontal exemption so a table's header rule is detected
again and the table run stays passthrough. Card gutters (└─ / │ / └) keep
their own prefix-based exemptions, so tool cards are unaffected. The only
cost is a real markdown hr copying as a dash run — the pre-#593 behavior.
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.