fix(tui): /resume <n> continues the nth saved session in place

In 1.x, "/resume <n>" switches straight to the nth saved session. The Studio
TUI ignored the number and always opened the picker, the known gap left in
the CLI migration tracking issue.

An indexed /resume now resolves the session by its row in the same list the
picker numbers and resumes it, with the same refusals the picker gives: a
running turn, the session already open, or an index outside the list. A bare
/resume (or a non-numeric argument) still opens the picker.

The index lookup shared by /takeover <n> and /rename <n> <title> moves into
one helper so the three commands cannot drift apart.

Refs #11493
This commit is contained in:
xxoingr committed 2026-10-06 00:35:42 +08:00
1 parent 198f60e6b4
commit 34c84f7a73
4 files changed
+88 -17

No files matched your search

+1 -1
View File
@@ -601,7 +601,7 @@ the displayed list matches the commands the TUI accepts.
| --- | --- |
| `/model` | Search configured models and switch the active model. |
| `/provider` | Choose a provider, then choose one of its configured models. |
| `/resume` | Search recent sessions and switch to one. |
| `/resume [n]` | Search recent sessions and switch to one; `/resume <n>` switches to the nth session of that list directly. |
| `/status` | Show model, effort, cache, Git, background jobs, and execution setting or balance details. |
| `/preset [balanced\|delivery]` | View or change the agent execution setting without rebuilding the controller. `/work-mode` and `/profile` remain compatibility aliases; `economy` and `light` resolve to `balanced`. |
| `/theme [auto\|light\|dark\|style]` | View or change the CLI background mode and accent palette. |
+1 -1
View File
@@ -512,7 +512,7 @@ SSH 下远端进程无法读取本机剪贴板,请使用终端粘贴快捷键
| --- | --- |
| `/model` | 搜索已配置模型并切换当前模型。 |
| `/provider` | 选择 provider,再选择该 provider 下的模型。 |
| `/resume` | 搜索最近会话并切换。 |
| `/resume [n]` | 搜索最近会话并切换;`/resume <n>` 直接切换到列表中第 n 个会话。 |
| `/status` | 显示模型、effort、cache、Git、后台任务,以及执行设定或余额信息。 |
| `/preset [balanced\|delivery]` | 查看或切换 Agent 执行设定(不重建 Controller);`/work-mode` 与 `/profile` 为兼容别名,`economy`、`light` 都解析为 `balanced`。 |
| `/theme [auto\|light\|dark\|style]` | 查看或切换 CLI 背景模式和强调色。 |
+48 -15
View File
@@ -41,6 +41,16 @@ func (m *model) queueSlash(display string) (tea.Cmd, bool) {
case "/takeover":
running := m.tr.Running
run = func() tea.Msg { return m.takeover(fields[1:], running) }
case "/resume":
if len(fields) != 2 {
return nil, false
}
idx, err := strconv.Atoi(fields[1])
if err != nil {
return nil, false
}
running := m.tr.Running
run = func() tea.Msg { return m.resumeByIndex(idx, running) }
case "/rename":
idx, title, ok := renameByIndexArgs(fields, rest)
if !ok {
@@ -83,22 +93,48 @@ func (m *model) takeover(args []string, running bool) tea.Msg {
}
target := args[0]
if idx, err := strconv.Atoi(target); err == nil {
list, err := m.client.Sessions(m.ctx)
if err != nil {
return slashDoneMsg{level: "error", text: "takeover: " + err.Error()}
s, refused := m.savedSession("takeover", idx)
if refused != nil {
return *refused
}
if idx < 1 || idx > len(list) {
return slashDoneMsg{level: "info", text: fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(list))}
}
if list[idx-1].Current {
if s.Current {
return slashDoneMsg{level: "info", text: i18n.M.ResumeAlreadyActive}
}
target = list[idx-1].Path
target = s.Path
}
err := m.client.Resume(m.ctx, target)
return takeoverMsg{err: err, text: i18n.M.TakeoverNoSteal}
}
// resumeByIndex continues the session at idx in the session list in place, as
// 1.x's "/resume <n>" does; a bare /resume opens the picker instead.
func (m *model) resumeByIndex(idx int, running bool) tea.Msg {
if running {
return slashDoneMsg{level: "warn", text: i18n.M.ResumeBusy}
}
s, refused := m.savedSession("resume", idx)
if refused != nil {
return *refused
}
if s.Current {
return slashDoneMsg{level: "info", text: i18n.M.ResumeAlreadyActive}
}
return resumedMsg{err: m.client.Resume(m.ctx, s.Path)}
}
// savedSession is the session listed at the 1-based idx, or the notice that
// says why there is none.
func (m *model) savedSession(cmd string, idx int) (SessionInfo, *slashDoneMsg) {
list, err := m.client.Sessions(m.ctx)
if err != nil {
return SessionInfo{}, &slashDoneMsg{level: "error", text: cmd + ": " + err.Error()}
}
if idx < 1 || idx > len(list) {
return SessionInfo{}, &slashDoneMsg{level: "info", text: fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(list))}
}
return list[idx-1], nil
}
// renameByIndexArgs recognises "/rename <n> <title>", the form that names a
// saved session by its position; any other shape is the current session's.
func renameByIndexArgs(fields []string, rest string) (idx int, title string, ok bool) {
@@ -113,14 +149,11 @@ func renameByIndexArgs(fields []string, rest string) (idx int, title string, ok
}
func (m *model) renameSession(idx int, title string) tea.Msg {
list, err := m.client.Sessions(m.ctx)
if err != nil {
return slashDoneMsg{level: "error", text: "rename: " + err.Error()}
s, refused := m.savedSession("rename", idx)
if refused != nil {
return *refused
}
if idx < 1 || idx > len(list) {
return slashDoneMsg{level: "info", text: fmt.Sprintf(i18n.M.ResumeBadIndexFmt, len(list))}
}
if err := m.client.RenameSession(m.ctx, list[idx-1].Name, title); err != nil {
if err := m.client.RenameSession(m.ctx, s.Name, title); err != nil {
return slashDoneMsg{level: "error", text: "rename: " + err.Error()}
}
return slashDoneMsg{level: "info", text: fmt.Sprintf(i18n.M.RenameDoneFmt, title)}
+38
View File
@@ -290,3 +290,41 @@ func TestRenameByIndexTitlesTheNthSavedSession(t *testing.T) {
t.Fatalf("out-of-range notice = %q", got)
}
}
// "/resume <n>" continues the nth saved session in place, as 1.x does; a bare
// /resume still opens the picker.
func TestResumeByIndexContinuesTheNthSavedSession(t *testing.T) {
m, k := queueModel(t, map[string]any{
"GET /sessions": []map[string]any{{"name": "a", "path": "/s/a.jsonl", "current": true}, {"name": "b", "path": "/s/b.jsonl"}},
})
queueSend(m, "/resume 2")
if !strings.Contains(k.seen(), `POST /resume {"path":"/s/b.jsonl"}`) {
t.Fatalf("resume did not target the second session:\n%s", k.seen())
}
if m.picker != nil {
t.Fatal("an indexed /resume must not open the picker")
}
queueSend(m, "/resume 1")
if got := queueLastNotice(m); got != "already in that session" {
t.Fatalf("current-session notice = %q", got)
}
queueSend(m, "/resume 9")
if got := queueLastNotice(m); !strings.Contains(got, "1–2") {
t.Fatalf("out-of-range notice = %q", got)
}
m.tr.Running = true
queueSend(m, "/resume 2")
m.tr.Running = false
if got := queueLastNotice(m); got != "finish or cancel the current turn before resuming" {
t.Fatalf("mid-turn notice = %q", got)
}
if n := strings.Count(k.seen(), "POST /resume"); n != 1 {
t.Fatalf("refused indexes still reached the kernel (%d resumes):\n%s", n, k.seen())
}
queueSend(m, "/resume")
if m.picker == nil {
t.Fatal("a bare /resume must open the picker")
}
}