Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@ Community needs voiced in HN agent-UI discussions map directly to EMRG's design:
pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.port; python -m emrg
```

Python: `uv run pytest tests/ -v` (695) — import check: `uv run python -c "from emrg.client.app import run_client"`
Python: `uv run pytest tests/ -v` (703) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (212: 43 daemon_client + 19 conn-manager + 22 app-commands + 91 renderer smoke + 15 i18n + 7 integration + 3 commands + 5 build-config + 7 gui-state) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`
CI: `uv run pytest` + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Expand Down
61 changes: 34 additions & 27 deletions emrg/client/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -211,14 +211,20 @@ async def interactive(init_auto_evolve: bool = False):
term = Terminal(); stdin_fd = sys.stdin.fileno()
stdin_queue: asyncio.Queue = asyncio.Queue()

def _status_left(title: str, sid: str) -> str:
"""Format left status: show both name and short ID for renamed sessions."""
def _status_left(title: str, sid: str, model: str = "") -> str:
"""Format left status: session title + short ID + current model."""
parts = []
if title:
return f"{title} ({sid[:8]})"
return sid
parts.append(f"{title} ({sid[:8]})")
else:
parts.append(sid)
if model:
parts.append(f"[{model}]")
return " ".join(parts)
busy = False; server_id = ""; need_new_assistant = False; session_title = ""
current_model = "" # model name tracked independently of server_id (rant 2026-08-11T20:02:43)

status = StatusLine(left=_status_left(session_title, session_id), center="connecting...")
status = StatusLine(left=_status_left(session_title, session_id, current_model), center="connecting...")
inp = InputWidget(); chat = ChatHistory()
term.mount(status=status, composer=inp, chat=chat)

Expand All @@ -240,12 +246,12 @@ def _short_path(p: str) -> str:
p = "…" + p[-29:]
return p

def _update_right() -> None:
def _update_left_extra() -> None:
if msg_count > 0:
status.update(right=f"{msg_count} msgs {_short_path(cwd)}")
status.update(left_extra=f"· {msg_count} msgs · {_short_path(cwd)}")
else:
status.update(right="Enter=send Esc=quit /help")
_update_right()
status.update(left_extra="Enter=send Esc=quit /help")
_update_left_extra()

_status_base: str = "" # base center text without timer, for elapsed timer overlay
_last_center: str = "" # last center text set via status.update, for timer overlay
Expand All @@ -257,7 +263,7 @@ async def _run_elapsed_timer() -> None:
try:
elapsed = int(time.time() - _request_start)
mins, secs = divmod(elapsed, 60)
timer = f"{mins}:{secs:02d}" if mins > 0 else f"⏱{secs}s"
timer = f"[{mins}:{secs:02d}]"
status.elapsed = timer
term.set_title(f"{timer} {session_title or session_id} @ {project_name}")
term.render()
Expand Down Expand Up @@ -295,6 +301,7 @@ def _render_throttled():

async def read_server():
nonlocal stream_buffer, status, history, chat, busy, server_id, need_new_assistant, session_id, session_title, msg_count, tool_args, _welcomed
nonlocal current_model
nonlocal _last_center, _elapsed_task, conn

async def _reconnect():
Expand Down Expand Up @@ -348,9 +355,9 @@ async def _reconnect():
ident = data.get("identity", {}); hid = ident.get("instance_id", "?")[:8]
host = ident.get("host_name", "?")
model = data.get("model", "")
server_id = f"{hid} @ {host}"
if model:
server_id += f" [{model}]"
current_model = model
server_id = f"{hid} @ {host}"
if not _welcomed:
_welcomed = True
import emrg
Expand All @@ -363,7 +370,7 @@ async def _reconnect():
await conn.send_command("update_check")
except Exception:
pass # never block chat on update check
status.update(left=_status_left(session_title, session_id), center=server_id)
status.update(left=_status_left(session_title, session_id, current_model), center=server_id)
term.set_title(f"{session_title or session_id} @ {project_name}")
term.render(); continue

Expand Down Expand Up @@ -473,7 +480,7 @@ async def _reconnect():
_last_center = server_id or "emrg"
status.update(center=_last_center)
term.set_title(f"{session_title or session_id} @ {project_name}")
msg_count += 1; _update_right()
msg_count += 1; _update_left_extra()
term.render()
if "error" in data:
err = data["error"]; logger.error("server error: %s", err)
Expand All @@ -490,7 +497,7 @@ async def _reconnect():
chat.dirty = True
chat.add("system", "Session cleared — starting fresh.")
msg_count = 0
_update_right()
_update_left_extra()
status.update(center=server_id or "emrg")
term.render()
continue
Expand All @@ -511,10 +518,10 @@ async def _reconnect():
chat.rows.clear()
chat.dirty = True
chat.add("system", f"Created new session {new_sid} — continue chatting.")
status.update(left=_status_left("", new_sid), center=server_id or "emrg")
status.update(left=_status_left("", new_sid, current_model), center=server_id or "emrg")
term.set_title(f"{new_sid} @ {project_name}")
msg_count = 0
_update_right()
_update_left_extra()
status.update(center=server_id or "emrg")
term.render()
continue
Expand All @@ -533,7 +540,7 @@ async def _reconnect():
msg_count = 0
# Reload session state from server
await conn.send_command("ping")
_update_right()
_update_left_extra()
status.update(center=server_id or "emrg")
term.render()
continue
Expand All @@ -555,7 +562,7 @@ async def _reconnect():
)
busy = False
msg_count = max(0, msg_count - compacted)
_update_right()
_update_left_extra()
status.elapsed = ""
status.update(center=server_id or "emrg"); term.render()
continue
Expand Down Expand Up @@ -685,10 +692,9 @@ async def _reconnect():
chat.add("system",
f"Model switched: {previous} → {model_name}"
f" (context: {ctx_win:,})")
# Update server_id so all subsequent status updates show the new model
base_id = server_id.split(" [")[0] if " [" in server_id else server_id
server_id = f"{base_id} [{model_name}]" if base_id else f"emrg [{model_name}]"
status.update(center=server_id)
# Track model independently and refresh the left section
current_model = model_name
status.update(left=_status_left(session_title, session_id, current_model), center=server_id)
term.render()
continue

Expand Down Expand Up @@ -860,11 +866,11 @@ async def _reconnect():
f"Resumed session {session_id}{title_extra} "
f"({meta.get('message_count', record_count)} messages, "
f"created {str(meta.get('created_at', ''))[:16].replace('T', ' ')})")
status.update(left=_status_left(session_title, session_id), center=server_id or "emrg")
status.update(left=_status_left(session_title, session_id, current_model), center=server_id or "emrg")
term.set_title(f"{session_title or session_id} @ {project_name}")
# Set message count from loaded session
msg_count = meta.get("message_count", record_count)
_update_right()
_update_left_extra()
term.render()
continue

Expand All @@ -877,7 +883,7 @@ async def _reconnect():
new_title = data.get("title", "")
session_title = new_title
chat.add("system", f"Session renamed to: {new_title}")
status.update(left=_status_left(session_title, session_id), center=server_id or "emrg")
status.update(left=_status_left(session_title, session_id, current_model), center=server_id or "emrg")
term.set_title(f"{session_title} @ {project_name}")
term.render()
continue
Expand Down Expand Up @@ -1067,6 +1073,7 @@ def _handle_selector_nav(data: bytes, widget) -> bool:

async def handle_key(data: bytes) -> bool:
nonlocal inp, status, history, paste_mode, stream_buffer, conn, chat, busy, need_new_assistant, session_id, session_title, msg_count, cwd
nonlocal current_model
nonlocal session_sel, delete_sel, project_sel, model_sel, rewind_sel, task_sel
nonlocal history_index, history_saved_input
nonlocal _autocomplete_active, _autocomplete_widget
Expand Down Expand Up @@ -1842,7 +1849,7 @@ def _is_image_token(s, i):
history.append(text); stream_buffer = ""
history_index = -1 # reset history navigation on submit
chat.add("assistant", "")
msg_count += 1; _update_right()
msg_count += 1; _update_left_extra()
logger.debug("ROWS after asst: %d [%s]", len(chat.rows),
', '.join(f'{r.role}={r.content[:20]}' for r in chat.rows if isinstance(r, ChatRow)))
_last_center = "thinking..."
Expand Down
31 changes: 9 additions & 22 deletions emrg/client/python_tui/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -254,24 +254,15 @@ def write_frame(
last_style_id = -1 # force transition on first diff cell
last_hyperlink_id = -1
has_output = False
row_dirty_end: dict[int, int] = {} # y → max x changed in that row

for x, y, prev, curr in diffs:
# When a WIDE character is at position x, the SPACER_TAIL at x+1
# must be protected from the trailing CLEAR_TO_EOL cleanup.
# If row_dirty_end stops at x, the cleanup CUP+EL would target the
# SPACER_TAIL cell and erase it, breaking the wide character on screen.
pw = int(getattr(prev, "width", 0))
cw = int(getattr(curr, "width", 0))
dirty_end = x + 1 if (pw == 1 or cw == 1) else x
row_dirty_end[y] = max(row_dirty_end.get(y, 0), dirty_end)

# SPACER_TAIL detection: wide chars occupy 2 cells (WIDE + SPACER_TAIL).
# The WIDE cell already advanced the terminal 2 columns, so the
# SPACER_TAIL cell must not reposition the cursor, write a character,
# or emit a style transition — it represents the terminal cursor's
# implicit position, not a cell that needs painting.
curr_char = getattr(curr, "char", " ")
cw = int(getattr(curr, "width", 0))
is_spacer = cw == 2

# Cursor positioning
Expand Down Expand Up @@ -308,18 +299,14 @@ def write_frame(
if not is_spacer:
parts.append(curr_char if curr_char else " ")
has_output = True

# Reset style and clear to end of each affected row
# This eliminates wide-character ghost artifacts (spacer tails, orphan cursors)
if row_dirty_end:
if last_style_id > 0:
parts.append("\x1b[0m")
last_style_id = 0
for y in sorted(row_dirty_end.keys(), reverse=True):
last_col = row_dirty_end[y]
# Move to one past the last changed cell, erase to end of line
parts.append(f"\x1b[{y + 1};{last_col + 2}H")
parts.append(CLEAR_TO_EOL)
# SPACER_TAIL cells need no output: a wide char advances the terminal
# cursor 2 columns, and its 2nd column inherently covers any stale glyph
# from the previous frame. Wide-char removal is handled by the
# inline-shrink path (prev char → curr empty) which writes a space
# through the normal branch above (rant 2026-08-11T19:59:09 / review
# 2026-08-11: an explicit space here would land one column PAST the
# spacer — the cursor is at x+2 after the WIDE cell, not x+1 — shifting
# chars after CJK insertions).

if sync:
parts.append(CURSOR_SHOW)
Expand Down
Loading
Loading