From 1c0664cf581b5f83b6bdbd84481ee1dd5d5f6cac Mon Sep 17 00:00:00 2001 From: CikeSeven Date: Sun, 11 Oct 2026 04:12:33 +0800 Subject: [PATCH 1/3] feat(sessions): follow browser focus with the native cursor Reuse native cursor declarations for workspace and session rows, the new-session card, and the live search field. Preserve navigation and Enter semantics, release background anchors for modals, and cover narrow and foreign-source panes with real terminal-cell regressions and synchronized guides. --- README.md | 2 +- README_ZH.md | 2 +- docs/interaction.en.md | 2 + docs/interaction.md | 2 + guide/dsh-tui-guide/interaction.en.md | 2 + guide/dsh-tui-guide/interaction.md | 2 + scripts/verify-session-supervisor.tsx | 92 ++++++++++++++++++- src/components/SearchBox.tsx | 5 +- src/components/sessions/ForeignSessionRow.tsx | 8 +- src/components/sessions/SessionListRow.tsx | 8 +- .../workspaces/HomeWorkspaceRow.tsx | 8 +- src/screens/SessionSupervisor.tsx | 82 +++++++++++++---- .../sessionSupervisor/ForeignSessionPanes.tsx | 42 +++++++-- 13 files changed, 225 insertions(+), 32 deletions(-) diff --git a/README.md b/README.md index c58e56a68..b29e17173 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ - **Pixel whale pet** — three startup intros, click to wake; freezes after the first task. - **Launchpad and first-run guide** — every launch lands on a landing page with a **real input box** (big text + whale + quick actions, dropping whole blocks on short/narrow terminals); command completion matches chat: `Tab` fills the input, `Enter` runs the command and clears it. The first run walks a four-step wizard (API key / language+theme / model+workspace / shortcuts), re-runnable with `/setup`. - **Terminal-native UI** — streaming Markdown, tool cards, `/` and `@` completion, `#L12-14` ranges, history search, zh/en UI. -- **Native cursor** — focused text inputs inherit your terminal's cursor shape, color, blinking, and enabled animation or trail effects in both inline and fullscreen modes. The `/model` picker shows the same cursor during movement between provider tabs, model rows, and effort levels, then hides it after **0.5 seconds at rest**; clipped regions fall back to the model row. Configure animation and trail effects in your terminal. +- **Native cursor** — focused text inputs inherit your terminal's cursor shape, color, blinking, and enabled animation or trail effects in both inline and fullscreen modes. The `/model` picker shows the same cursor during movement between provider tabs, model rows, and effort levels, then hides it after **0.5 seconds at rest**; clipped regions fall back to the model row. The session manager also moves this cursor between the workspace rail, session rows, and search box; typing claims the search caret, and navigation returns it to the focused row. Configure animation and trail effects in your terminal. - **Images** — Kitty/Sixel thumbnails, centered preview with zoom and pan, paste-time fitting, text fallback. - **Mermaid diagrams** — ````mermaid ```` fences drawn as Unicode diagrams. - **LaTeX math** — `$…$` and `$$…$$` formulas as Unicode text, fractions and limits stacked in display blocks; `mathRendering: image` typesets block and one-row inline formulas as terminal images on graphics terminals. diff --git a/README_ZH.md b/README_ZH.md index 5d4568364..e6a2acba6 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -26,7 +26,7 @@ - **像素鲸鱼娘** — 开屏三选一动画,点击唤醒;开始第一个任务后定格。 - **落地页与首次引导** — 每次启动先落在带**真输入框**的落地页(大字 + 鲸鱼 + 快捷入口,窄/矮终端自动整块降级);命令补全与聊天页一致:`Tab` 填入输入框,`Enter` 执行并清空。首次运行走四步向导(API Key / 语言主题 / 模型工作区 / 快捷键),`/setup` 随时重跑。 - **终端原生界面** — 流式 Markdown、工具卡、`/` 与 `@` 补全、`#L12-14` 行区间、历史搜索、中英界面。 -- **原生光标** — 聚焦的文本输入框继承终端的光标形状、颜色、闪烁及已启用的动画或拖尾效果,inline 与 fullscreen 均支持。`/model` 选择器在提供商标签、模型行和推理档位之间移动时显示同样的光标,停稳 **0.5 秒后隐藏**;区域被裁剪时回落到模型行。动画与拖尾效果在终端设置中配置。 +- **原生光标** — 聚焦的文本输入框继承终端的光标形状、颜色、闪烁及已启用的动画或拖尾效果,inline 与 fullscreen 均支持。`/model` 选择器在提供商标签、模型行和推理档位之间移动时显示同样的光标,停稳 **0.5 秒后隐藏**;区域被裁剪时回落到模型行。会话管理界面也会在工作区栏、会话行与搜索框之间移动原生光标;输入时移到搜索框,导航时回到聚焦行。动画与拖尾效果在终端设置中配置。 - **图片** — Kitty/Sixel 缩略图,居中大图可缩放平移,粘贴前按限额适配,无图形时文字回退。 - **Mermaid 图表** — ```` ```mermaid ```` 代码块画成 Unicode 字符图。 - **LaTeX 公式** — `$…$` 与 `$$…$$` 公式转成 Unicode 文本,块级公式里的分数与上下限竖排;`mathRendering: image` 时在支持图形的终端里把块级公式与能压成一行的行内公式排成终端图片。 diff --git a/docs/interaction.en.md b/docs/interaction.en.md index 0b105a888..dd1173528 100644 --- a/docs/interaction.en.md +++ b/docs/interaction.en.md @@ -363,6 +363,8 @@ brings it back. | `Tab` / `Shift+Tab` | Next / previous source tab (when other agents have sessions) | | `Esc` | Dismiss a notice → clear the filter → leave the screen | +The native cursor follows the last-used region, as in the model picker: workspace navigation parks it on the workspace row, session navigation on the session row or new-session card, and typing or clicking the filter on its text caret. Row carets hide after 0.5 seconds at rest; the search caret stays visible. Animation and trail effects come from your terminal settings. Narrow layouts keep the caret on a visible target when the workspace rail is hidden. Arrow and Enter actions keep the same meaning while filtering. + The right side of the title row is the **source strip**: `DSH │ Claude Code Codex …`. - A tab appears only for another coding agent on this machine (Claude Code, Codex, Grok Build, zcode) that has conversations, in a fixed order; with none, there is no strip. Opening the screen only checks whether each source has any conversation; a source's list is read when its tab is opened. diff --git a/docs/interaction.md b/docs/interaction.md index 8172526c3..8c5efc458 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -366,6 +366,8 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 外部标签下 `Ctrl+N`、`Ctrl+X`、固定不生效;`Ctrl+L` 改为重新扫描该来源。 - 导入与 `/migrate` 用同一套解析和确定性 id,两个入口导入同一条会话得到的是同一个 DSH 会话。 +原生光标与模型选择器一样跟随最后操作的区域:导航工作区时停在工作区行,导航会话时停在会话行或“新建会话”卡片,输入或点击筛选框时移到文本光标。行光标停稳 0.5 秒后隐藏,搜索光标保持可见;动画与拖尾沿用终端设置。窄屏隐藏工作区栏后,光标会回落到可见位置。筛选期间方向键与 Enter 的作用保持一致。 + 工作区栏的菜单有四项: - **编辑**:选中该工作区,右侧列出它的会话。 diff --git a/guide/dsh-tui-guide/interaction.en.md b/guide/dsh-tui-guide/interaction.en.md index 0b105a888..dd1173528 100644 --- a/guide/dsh-tui-guide/interaction.en.md +++ b/guide/dsh-tui-guide/interaction.en.md @@ -363,6 +363,8 @@ brings it back. | `Tab` / `Shift+Tab` | Next / previous source tab (when other agents have sessions) | | `Esc` | Dismiss a notice → clear the filter → leave the screen | +The native cursor follows the last-used region, as in the model picker: workspace navigation parks it on the workspace row, session navigation on the session row or new-session card, and typing or clicking the filter on its text caret. Row carets hide after 0.5 seconds at rest; the search caret stays visible. Animation and trail effects come from your terminal settings. Narrow layouts keep the caret on a visible target when the workspace rail is hidden. Arrow and Enter actions keep the same meaning while filtering. + The right side of the title row is the **source strip**: `DSH │ Claude Code Codex …`. - A tab appears only for another coding agent on this machine (Claude Code, Codex, Grok Build, zcode) that has conversations, in a fixed order; with none, there is no strip. Opening the screen only checks whether each source has any conversation; a source's list is read when its tab is opened. diff --git a/guide/dsh-tui-guide/interaction.md b/guide/dsh-tui-guide/interaction.md index 8172526c3..8c5efc458 100644 --- a/guide/dsh-tui-guide/interaction.md +++ b/guide/dsh-tui-guide/interaction.md @@ -366,6 +366,8 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 外部标签下 `Ctrl+N`、`Ctrl+X`、固定不生效;`Ctrl+L` 改为重新扫描该来源。 - 导入与 `/migrate` 用同一套解析和确定性 id,两个入口导入同一条会话得到的是同一个 DSH 会话。 +原生光标与模型选择器一样跟随最后操作的区域:导航工作区时停在工作区行,导航会话时停在会话行或“新建会话”卡片,输入或点击筛选框时移到文本光标。行光标停稳 0.5 秒后隐藏,搜索光标保持可见;动画与拖尾沿用终端设置。窄屏隐藏工作区栏后,光标会回落到可见位置。筛选期间方向键与 Enter 的作用保持一致。 + 工作区栏的菜单有四项: - **编辑**:选中该工作区,右侧列出它的会话。 diff --git a/scripts/verify-session-supervisor.tsx b/scripts/verify-session-supervisor.tsx index b283e9275..d4c38bd03 100644 --- a/scripts/verify-session-supervisor.tsx +++ b/scripts/verify-session-supervisor.tsx @@ -287,6 +287,8 @@ interface StubChannelConfig { readonly openResult?: { ok: true } | { ok: false; reason: 'failed'; error: string } | { ok: false; reason: 'cancelled' } /** Terminal width for this screen; {@link COLS} by default. */ readonly cols?: number + /** Exercise the normal screen buffer as well as fullscreen. */ + readonly inline?: boolean /** * The persistent cache this channel reads. * @@ -457,6 +459,10 @@ function foreignFacade(config: ForeignStubConfig, calls: string[]): Record void lines: () => string[] + /** Physical terminal caret, independent of the painted focus marker. */ + cursor: () => { col: number; row: number; visible: boolean } + /** Read a display cell without treating CJK text offsets as columns. */ + cell: (col: number, row: number) => string /** One real SGR click on the first occurrence of `needle`. */ click: (needle: string) => Promise /** One real SGR right click on the first occurrence of `needle`. */ @@ -483,9 +489,17 @@ async function mountSupervisor(target: StubChannel): Promise { const screen = new XTerm({ cols: target.config.cols ?? COLS, rows: ROWS, scrollback: 0, allowProposedApi: true }) const out = new FakeStdout(screen) const input = new FakeStdin() + let cursorVisible = true + for (const [final, visible] of [['h', true], ['l', false]] as const) { + screen.parser.registerCsiHandler({ prefix: '?', final }, params => { + if (params.includes(25)) cursorVisible = visible + return false + }) + } + const ScreenMode = target.config.inline ? React.Fragment : AlternateScreen const app = await render( - + { onApprove={() => {}} liveStateOf={(id) => liveState[id as keyof typeof liveState]} /> - + , { stdin: input as never, @@ -516,6 +530,8 @@ async function mountSupervisor(target: StubChannel): Promise { return { write: (data: string) => { input.write(data) }, lines: () => viewportLines(screen), + cursor: () => ({ col: screen.buffer.active.cursorX, row: screen.buffer.active.cursorY, visible: cursorVisible }), + cell: (col, row) => screen.buffer.active.getLine(screen.buffer.active.baseY + row)?.getCell(col)?.getChars() ?? '', click: async (needle: string) => { await settled(() => findText(screen, needle) !== null) const found = findText(screen, needle) @@ -541,6 +557,62 @@ async function openSupervisor(config: StubChannelConfig): Promise ({ ...entry, title: `${entry.title} 工作区` })), cwd: alphaDir, cols, inline }) + const markerAt = (title: string): boolean => { + const cursor = app.cursor() + const line = app.lines()[cursor.row] ?? '' + return cursor.visible && line.includes(title) && app.cell(cursor.col, cursor.row) === '❯' + } + const searchAt = (query: string, cells: number): boolean => { + const cursor = app.cursor() + const line = app.lines()[cursor.row] ?? '' + const prefix = line.indexOf(`/ ${query}`) + return cursor.visible && prefix >= 0 && app.cell(cursor.col - 2 - cells, cursor.row) === '/' + } + const label = `${inline ? 'inline' : 'fullscreen'} ${cols} cols` + try { + check(`${label}: opening parks the native caret on the focused session`, await settled(() => markerAt('free session'))) + if (cols === 120) { + app.write('\u001b[D') + check(`${label}: left hands the caret to the selected workspace`, await settled(() => markerAt('Alpha'))) + app.write('free') + check(`${label}: typing from the rail hands the caret to the live filter`, await settled(() => searchAt('free', 4))) + app.write('\u001b[D') + check(`${label}: navigating away from the filter restores the rail caret`, await settled(() => markerAt('Alpha'))) + app.write('\u001b') + await settled(() => app.lines().some(line => line.includes('live session'))) + } else { + app.write('\u001b[D') + check(`${label}: a hidden rail falls back to the visible filter caret`, await settled(() => searchAt('', 0))) + } + app.write('\u001b[C') + await settled(() => markerAt('live session')) + for (let step = 0; step < sessions.length && !markerAt('+ New session'); step++) { + const previous = app.cursor().row + app.write('\u001b[A') + await settled(() => app.cursor().row !== previous) + } + check(`${label}: the new-session card owns the same list caret`, await settled(() => markerAt('+ New session'))) + app.write('\u001b[B') + check(`${label}: down moves the native caret to a session row`, await settled(() => markerAt('free session') || markerAt('live session'))) + app.write('中') + check(`${label}: the search caret counts CJK display cells`, await settled(() => searchAt('中', 2))) + app.write('\u007f') + await settled(() => app.lines().some(line => line.includes('live session'))) + app.write('\u001b[C') + check(`${label}: list navigation returns the native caret from search`, await settled(() => markerAt('live session'))) + if (!inline) { + // SGR mouse hit-testing is enabled in fullscreen; inline uses keys. + await app.click('Type to search sessions') + check(`${label}: clicking the filter claims its native caret`, await settled(() => searchAt('', 0))) + } + } finally { app.close() } + } +} + // ── snapshot-then-refresh (issue #987) ───────────────────────────────────── // // What a mount paints BEFORE the fresh listing lands. The snapshot lives on @@ -1842,8 +1914,18 @@ console.log('source tabs: strip, switching and import') check('there is no new-session card', !shown().includes('+ New session'), shown()) const cursorOn = (title: string): boolean => app.lines().some(line => line.includes(title) && line.includes('❯')) + const nativeCursorOn = (title: string): boolean => { + const cursor = app.cursor() + const line = app.lines()[cursor.row] ?? '' + return cursor.visible && line.includes(title) && app.cell(cursor.col, cursor.row) === '❯' + } app.write('\u001b[C') check('→ lands the cursor on the first row (no card at 0)', await settled(() => cursorOn('fix the parser')), shown()) + check('the foreign session row owns the native caret', await settled(() => nativeCursorOn('fix the parser'))) + app.write('\u001b[D') + check('the foreign workspace takes the native caret', await settled(() => nativeCursorOn('Alpha'))) + app.write('\u001b[C') + await settled(() => nativeCursorOn('fix the parser')) for (const character of 'docs') { app.write(character) await sleep(60) // 固定窗:pacing 逐字投喂:整串一次写入时首字符会被当作导航键吞掉 @@ -1854,6 +1936,12 @@ console.log('source tabs: strip, switching and import') shown(), ) check('the filtered cursor stands on a real row', await settled(() => cursorOn('write the docs')), shown()) + check('typing in a foreign source hands the native caret to its filter', await settled(() => { + const cursor = app.cursor() + const line = app.lines()[cursor.row] ?? '' + const prefix = line.indexOf('/ docs') + return cursor.visible && prefix >= 0 && app.cell(cursor.col - 6, cursor.row) === '/' + })) await sleep(120) // 固定窗:pacing Enter 处理步间,无可观测锚点 app.write('\r') check( diff --git a/src/components/SearchBox.tsx b/src/components/SearchBox.tsx index eb260cfc9..a67b6df3e 100644 --- a/src/components/SearchBox.tsx +++ b/src/components/SearchBox.tsx @@ -87,6 +87,7 @@ export function SearchBox({ cursorOffset, borderless = false, caretBlink = true, + declareCursor = true, placeholderAlign = 'right', imageSpans = [], }: { @@ -111,6 +112,8 @@ export function SearchBox({ * TTY 光标的闪烁与动画由终端配置控制。 */ caretBlink?: boolean + /** Keep the live field styled while another region owns the native caret. */ + declareCursor?: boolean }): React.ReactNode { const [themeName] = useTheme() const nativeCursor = useNativeCursor() @@ -173,7 +176,7 @@ export function SearchBox({ const declarationRef = useDeclaredCursor({ line: borderless ? 0 : 1, column: caretColumn, - active: showCaret, + active: showCaret && declareCursor, visible: nativeCursor, }) const boxNodeRef = useRef(null) diff --git a/src/components/sessions/ForeignSessionRow.tsx b/src/components/sessions/ForeignSessionRow.tsx index 376355cfb..423183007 100644 --- a/src/components/sessions/ForeignSessionRow.tsx +++ b/src/components/sessions/ForeignSessionRow.tsx @@ -4,6 +4,7 @@ import type { ClickEvent } from '../../ink/events/click-event.js' import { useTooltip } from '../Tooltip.js' import { formatAbsolute, formatProject, formatWhen, truncateWidth } from '../../sessions/format.js' import type { ForeignSessionRow as ForeignSessionSummary } from '../../adapter/ports/channel-session.js' +import { useDeclaredCursor, useNativeCursor } from '../../ink/hooks/use-declared-cursor.js' /** * One conversation of another agent in a source tab: its title, and a facts @@ -27,6 +28,7 @@ export function ForeignSessionRow({ session, width, focused, + declareCursor = false, home, now, onClick, @@ -35,6 +37,8 @@ export function ForeignSessionRow({ /** Columns available to the row. */ width: number focused: boolean + /** Park the native caret on this row while the list owns it. */ + declareCursor?: boolean /** Home directory, for collapsing the path to `~`. */ home: string /** Epoch ms used for every relative time in this render pass. */ @@ -43,6 +47,8 @@ export function ForeignSessionRow({ onClick?(event: ClickEvent): void }): React.ReactNode { const [hovered, setHovered] = useState(false) + const nativeCursor = useNativeCursor() + const cursorRef = useDeclaredCursor({ line: 0, column: 0, active: focused && declareCursor, visible: nativeCursor, hideOnIdle: true }) // Two cells for the focus marker. const body = Math.max(8, width - 2) const shownTitle = truncateWidth(session.title, Math.max(4, body - 2)) @@ -65,7 +71,7 @@ export function ForeignSessionRow({ // the green foreground below, as on the DSH row. backgroundColor={hovered && !focused ? 'userMessageBackgroundHover' : undefined} > - + {focused ? '❯ ' : ' '} {shownTitle} diff --git a/src/components/sessions/SessionListRow.tsx b/src/components/sessions/SessionListRow.tsx index 4f137dfa5..0e8c7c641 100644 --- a/src/components/sessions/SessionListRow.tsx +++ b/src/components/sessions/SessionListRow.tsx @@ -6,6 +6,7 @@ import type { ContextMenuEvent } from '../../ink/events/context-menu-event.js' import { useTooltip } from '../Tooltip.js' import { SpinnerGlyph } from '../Spinner/SpinnerGlyph.js' import { stringWidth } from '../../ink/stringWidth.js' +import { useDeclaredCursor, useNativeCursor } from '../../ink/hooks/use-declared-cursor.js' import { formatAbsolute, formatBytes, @@ -59,6 +60,7 @@ export function SessionListRow({ width, depth, focused, + declareCursor = false, pinned, now, onClick, @@ -75,6 +77,8 @@ export function SessionListRow({ /** 0 for a conversation, 1 for a sub-agent run under its parent. */ depth: number focused: boolean + /** Park the native caret on this row while the list owns it. */ + declareCursor?: boolean /** Whether the user pinned this session to the top of the browser. */ pinned: boolean /** Epoch ms used for every relative time in this render pass. */ @@ -105,6 +109,8 @@ export function SessionListRow({ spinner?: { frame: number; time: number } }): React.ReactNode { const indent = depth * 2 + const nativeCursor = useNativeCursor() + const cursorRef = useDeclaredCursor({ line: 0, column: indent, active: focused && declareCursor, visible: nativeCursor, hideOnIdle: true }) // Two cells for the focus marker, plus the indent for a nested run. const body = Math.max(8, width - 2 - indent) const mark = kindMark(session.kind) @@ -166,7 +172,7 @@ export function SessionListRow({ // background and a selected row keeps its own colour while hovered. backgroundColor={hovered && !focused ? 'userMessageBackgroundHover' : undefined} > - + {`${' '.repeat(indent)}${focused ? '❯ ' : ' '}`} diff --git a/src/components/workspaces/HomeWorkspaceRow.tsx b/src/components/workspaces/HomeWorkspaceRow.tsx index 17637f352..6d3ee0950 100644 --- a/src/components/workspaces/HomeWorkspaceRow.tsx +++ b/src/components/workspaces/HomeWorkspaceRow.tsx @@ -4,6 +4,7 @@ import { t } from '../../i18n.js' import type { ClickEvent } from '../../ink/events/click-event.js' import type { ContextMenuEvent } from '../../ink/events/context-menu-event.js' import { formatProject, spreadRow, truncateWidth } from '../../sessions/format.js' +import { useDeclaredCursor, useNativeCursor } from '../../ink/hooks/use-declared-cursor.js' /** * One workspace in the home screen's left rail. @@ -27,6 +28,7 @@ export function HomeWorkspaceRow({ present, selected, focused, + declareCursor = false, width, onSelect, onMenu, @@ -45,6 +47,8 @@ export function HomeWorkspaceRow({ selected: boolean /** Keyboard cursor. */ focused: boolean + /** Park the native caret on this row while the rail owns it. */ + declareCursor?: boolean /** Columns available to the row. */ width: number /** Left click: show this workspace's sessions (same path as Enter). */ @@ -53,6 +57,8 @@ export function HomeWorkspaceRow({ onMenu?(event: ContextMenuEvent): void }): React.ReactNode { const [hovered, setHovered] = useState(false) + const nativeCursor = useNativeCursor() + const cursorRef = useDeclaredCursor({ line: 0, column: 0, active: focused && declareCursor, visible: nativeCursor, hideOnIdle: true }) const body = Math.max(8, width - 4) // The presence marker leads the badge on purpose: `spreadRow` may truncate // the right-hand segment, and "this directory is gone" is the one fact that @@ -81,7 +87,7 @@ export function HomeWorkspaceRow({ // in the text, which is why this row draws no box while selected. backgroundColor={hovered && !focused ? 'userMessageBackgroundHover' : undefined} > - + {focused ? '❯ ' : ' '} {heading.left} diff --git a/src/screens/SessionSupervisor.tsx b/src/screens/SessionSupervisor.tsx index 557f5e823..0b6c2bc7f 100644 --- a/src/screens/SessionSupervisor.tsx +++ b/src/screens/SessionSupervisor.tsx @@ -15,6 +15,7 @@ import { SpinnerGlyph } from '../components/Spinner/SpinnerGlyph.js' import { ApprovalPanel } from '../components/approvals/ApprovalPanel.js' import type { PermissionPanelDecision, PermissionPanelOutcome, PermissionPanelSnapshot } from '../channel/permissions.js' import { useTerminalFocus } from '../ink/hooks/use-terminal-focus.js' +import { useDeclaredCursor, useNativeCursor } from '../ink/hooks/use-declared-cursor.js' import { useAnimationFrame } from '../ink/hooks/use-animation-frame.js' import { isPlainReturn, isMod } from '../utils/modifiers.js' import { truncateWidth } from '../sessions/format.js' @@ -176,6 +177,25 @@ export function SessionSupervisor({ const [tab, setTab] = useState(DSH_TAB) /** The `+N` dropdown of folded tabs, anchored where it was clicked. */ const [tabMenu, setTabMenu] = useState<{ col: number; row: number; item: number } | undefined>(undefined) + // Like ModelPicker, the native caret follows the region last touched. + // The live filter keeps the rail/list's existing navigation and Enter target. + const [filterFocused, setFilterFocused] = useState(false) + const cursorActive = approval === null && menu === undefined && tabMenu === undefined + && rename === undefined && confirmRemove === undefined && sessionRename === undefined && confirmDelete === undefined + const searchCursor = filterFocused || (!railVisible && activePane === 'rail') + const nativeCursor = useNativeCursor() + const newSessionCursorRef = useDeclaredCursor({ + line: 0, column: 0, active: tab === DSH_TAB && cursorActive && !searchCursor && cardFocused, + visible: nativeCursor, hideOnIdle: true, + }) + const focusRail = (): void => { + setFilterFocused(false) + activateRail() + } + const focusList = (): void => { + setFilterFocused(false) + activateList() + } const tabMenuRef = useRef(tabMenu) tabMenuRef.current = tabMenu /** The session-rename draft as the keyboard sees it (see the input handler). */ @@ -214,6 +234,7 @@ export function SessionSupervisor({ closeMenu() closeTabMenu() setQuery('') + setFilterFocused(false) setNotice(undefined) setTab(id) }, [closeMenu, closeTabMenu, setQuery, setNotice]) @@ -337,6 +358,7 @@ export function SessionSupervisor({ return } if (queryRef.current.length > 0) { + setFilterFocused(true) setQuery('') return } @@ -350,6 +372,9 @@ export function SessionSupervisor({ cycleTab(key.shift ? -1 : 1) return } + if (key.leftArrow || key.rightArrow || key.upArrow || key.downArrow || key.wheelUp || key.wheelDown || key.pageUp || key.pageDown) { + setFilterFocused(false) + } if (tab !== DSH_TAB) { foreignKey(input, key) return @@ -379,12 +404,10 @@ export function SessionSupervisor({ else moveRail(key.pageDown ? 1 : -1) return } - // The filter is a LIVE query, not a mode you enter: this screen has no - // second cursor for a text seat, and the rail/list already own the arrows - // (a seat would have to relearn them). So printable input goes straight to - // the query — without this branch the box rendered, focused, and could never - // be typed into. + // Typing moves the native caret into the live filter; arrows and Enter + // still belong to the rail/list, so filtering adds no separate input mode. if (key.backspace || key.delete) { + setFilterFocused(true) setQuery(text => text.slice(0, -1)) return } @@ -448,7 +471,10 @@ export function SessionSupervisor({ // unknown key cannot type an invisible glyph into the query. if (!isMod(key) && !key.meta && !key.super && input && !key.return) { const typed = input.replace(/\p{Cc}/gu, '') - if (typed.length > 0) setQuery(text => text + typed) + if (typed.length > 0) { + setFilterFocused(true) + setQuery(text => text + typed) + } } }) @@ -482,6 +508,7 @@ export function SessionSupervisor({ return } if (key.backspace || key.delete) { + setFilterFocused(true) setQuery(text => text.slice(0, -1)) return } @@ -498,7 +525,10 @@ export function SessionSupervisor({ } if (!key.meta && !key.super && input && !key.return) { const typed = input.replace(/\p{Cc}/gu, '') - if (typed.length > 0) setQuery(text => text + typed) + if (typed.length > 0) { + setFilterFocused(true) + setQuery(text => text + typed) + } } } @@ -596,6 +626,9 @@ export function SessionSupervisor({ sessionWidth={sessionWidth} rows={rows} isTerminalFocused={isTerminalFocused} + filterFocused={filterFocused} + cursorActive={cursorActive} + onFilterFocus={setFilterFocused} /> )} {tab === DSH_TAB && ( @@ -603,9 +636,10 @@ export function SessionSupervisor({ {railVisible && ( { + setFilterFocused(false) moveRail(event.deltaY >= 0 ? 1 : -1) }} > @@ -635,9 +669,11 @@ export function SessionSupervisor({ present={entry.present} selected={selected !== undefined && selected.id === entry.id} focused={activePane === 'rail' && railFocus === absolute} + declareCursor={cursorActive && !searchCursor} width={railWidth} onSelect={(event): void => { event.stopImmediatePropagation() + setFilterFocused(false) railRef.current = absolute setRailFocus(absolute) selectEntry(entry) @@ -672,8 +708,8 @@ export function SessionSupervisor({ height="100%" flexShrink={0} overflow="hidden" - onClick={activateList} - onMouseEnter={activateList} + onClick={focusList} + onMouseEnter={focusList} > @@ -686,14 +722,19 @@ export function SessionSupervisor({ - - {/* Always live: the keyboard feeds this query on every printable - key (see useInput), so a box that is "unfocused" while the - cursor rests on the first rail entry would be a lie — and it is - exactly where the cursor starts. */} + { + event.stopImmediatePropagation() + activateList() + setFilterFocused(true) + }} onMouseEnter={event => { + event.stopImmediatePropagation() + if (activePane !== 'list') activateList() + setFilterFocused(true) + }}> setFilterFocused(false)} onClick={(event: ClickEvent): void => { event.stopImmediatePropagation() if (selected !== undefined) newSessionIn(selected) @@ -719,7 +761,7 @@ export function SessionSupervisor({ > {/* Row 0 of the session list, so it carries the cursor like any other card — and only while the cursor is actually on it. */} - + {cardFocused ? '❯ ' : ' '} {t('supervisor-new-session')} @@ -729,7 +771,9 @@ export function SessionSupervisor({ setFilterFocused(false)} onWheel={(event: WheelEvent): void => { + setFilterFocused(false) moveSession(event.deltaY >= 0 ? 1 : -1) }} > @@ -749,6 +793,7 @@ export function SessionSupervisor({ width={sessionWidth} depth={0} focused={activePane === 'list' && sessionTop + index + 1 === sessionIndex} + declareCursor={cursorActive && !searchCursor} pinned={pins.has(session.id)} now={now} liveStatus={state?.live === true ? state.status : undefined} @@ -761,6 +806,7 @@ export function SessionSupervisor({ openSession(session) }} onTogglePin={(): void => { + setFilterFocused(false) setFocusSessionId(session.id) persistPin(session.id, !pins.has(session.id)) }} diff --git a/src/screens/sessionSupervisor/ForeignSessionPanes.tsx b/src/screens/sessionSupervisor/ForeignSessionPanes.tsx index 289c7ef77..a68b11a81 100644 --- a/src/screens/sessionSupervisor/ForeignSessionPanes.tsx +++ b/src/screens/sessionSupervisor/ForeignSessionPanes.tsx @@ -54,6 +54,9 @@ export function ForeignSessionPanes({ sessionWidth, rows, isTerminalFocused, + filterFocused, + cursorActive, + onFilterFocus, }: { model: ForeignSessionsModel /** The active source's display name. */ @@ -69,8 +72,20 @@ export function ForeignSessionPanes({ /** Terminal rows of the whole screen. */ rows: number isTerminalFocused: boolean + filterFocused: boolean + cursorActive: boolean + onFilterFocus(focused: boolean): void }): React.ReactNode { const { groups, selected, railFocus, visibleRows, rowIndex, pane, loading } = model + const searchCursor = filterFocused || (!railVisible && pane === 'rail') || (pane === 'list' && visibleRows.length === 0) + const activateRail = (): void => { + onFilterFocus(false) + model.activateRail() + } + const activateList = (): void => { + onFilterFocus(false) + model.activateList() + } const filtered = query.trim().length > 0 const railTop = windowTop(railFocus, groups.length, railEntryCapacity) @@ -89,9 +104,10 @@ export function ForeignSessionPanes({ {railVisible && ( { + onFilterFocus(false) model.moveRail(event.deltaY >= 0 ? 1 : -1) }} > @@ -115,9 +131,11 @@ export function ForeignSessionPanes({ present={group.present} selected={selected?.key === group.key} focused={pane === 'rail' && railFocus === absolute} + declareCursor={cursorActive && !searchCursor} width={railWidth} onSelect={(event): void => { event.stopImmediatePropagation() + onFilterFocus(false) model.selectGroup(group) }} /> @@ -142,8 +160,8 @@ export function ForeignSessionPanes({ height="100%" flexShrink={0} overflow="hidden" - onClick={model.activateList} - onMouseEnter={model.activateList} + onClick={activateList} + onMouseEnter={activateList} > @@ -155,10 +173,19 @@ export function ForeignSessionPanes({ - + { + event.stopImmediatePropagation() + model.activateList() + onFilterFocus(true) + }} onMouseEnter={event => { + event.stopImmediatePropagation() + if (pane !== 'list') model.activateList() + onFilterFocus(true) + }}> onFilterFocus(false)} onWheel={(event: WheelEvent): void => { + onFilterFocus(false) model.moveList(event.deltaY >= 0 ? 1 : -1) }} > @@ -184,6 +213,7 @@ export function ForeignSessionPanes({ session={row} width={sessionWidth} focused={pane === 'list' && listTop + index === rowIndex} + declareCursor={cursorActive && !searchCursor} home={home} now={now} onClick={(event): void => { From 8cd27e45468c68de351a06826ff009d4cd4958a2 Mon Sep 17 00:00:00 2001 From: CikeSeven Date: Sun, 11 Oct 2026 04:35:57 +0800 Subject: [PATCH 2/3] fix(sessions): preserve pane selection and default workspaces to newest Keep the selected session when moving between panes or entering the list with the mouse. Reset to the newest matching session only when the workspace changes, follow delayed listings until the user chooses a row, and retain a single selection without per-workspace memory. Cover DSH, Claude, Codex, mouse motion, cold listings, and explicit new-session choices. --- README.md | 2 +- README_ZH.md | 2 +- docs/interaction.en.md | 4 +- docs/interaction.md | 4 +- guide/dsh-tui-guide/interaction.en.md | 4 +- guide/dsh-tui-guide/interaction.md | 4 +- scripts/verify-session-supervisor.tsx | 108 ++++++++++++++++-- .../sessionSupervisor/useSessionSupervisor.ts | 53 +++++---- 8 files changed, 138 insertions(+), 43 deletions(-) diff --git a/README.md b/README.md index b29e17173..93da353b8 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ Shortcuts sit below the title and effort levels have their own section; DSH, Cla In `/provider`'s model list, focus a model and press `Tab` to edit its context window, max output tokens, reasoning efforts, and image input capability. -The session manager focuses the most recently used session in the current workspace; if there is no history, it focuses the new-session card. Press `←` to move to the workspace rail. It paints the last successful list immediately while it checks the persistence store for changes. This first-paint snapshot survives restarts on DSH, Claude and Codex and is isolated by backend and storage directory. Without a snapshot, all three backends prioritize the current workspace before filling in other workspaces. Claude and Codex first request up to 32 recent sessions within their native cwd scope: Claude's SDK includes Git worktrees in the same repository, while Codex matches the session cwd exactly. The browser groups sessions by their own cwd. DSH resolves the current workspace's metadata first and publishes it before reading other workspaces' logs, even when it has fewer than 32 sessions. Titles that require a deeper DSH log scan appear first with a fallback name and update in place when recovery finishes. +The session manager focuses the most recently used session in the current workspace; if there is no history, it focuses the new-session card. Moving between the workspace rail and session list preserves the selected session, including when the mouse enters the list. Switching to another workspace selects its latest session; switching back also starts from its latest session instead of restoring an earlier selection. Press `←` to move to the workspace rail. It paints the last successful list immediately while it checks the persistence store for changes. This first-paint snapshot survives restarts on DSH, Claude and Codex and is isolated by backend and storage directory. Without a snapshot, all three backends prioritize the current workspace before filling in other workspaces. Claude and Codex first request up to 32 recent sessions within their native cwd scope: Claude's SDK includes Git worktrees in the same repository, while Codex matches the session cwd exactly. The browser groups sessions by their own cwd. DSH resolves the current workspace's metadata first and publishes it before reading other workspaces' logs, even when it has fewer than 32 sessions. Titles that require a deeper DSH log scan appear first with a fallback name and update in place when recovery finishes. With DSH's current JSONL backend, startup and `/new` keep initial permission events in memory until further session activity or an explicit durability flush saves the complete log. Restarting an unstored empty session starts fresh. Removing a workspace registration keeps its sessions accessible under a "History only" directory in the rail. History-only directories offer edit and new-session actions; rename and remove are available for registered workspaces. diff --git a/README_ZH.md b/README_ZH.md index e6a2acba6..54ad65a77 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -269,7 +269,7 @@ OpenAI 条款约束。完整操作与当前边界:[Codex 后端](docs/codex-ba 在 `/provider` 模型列表聚焦一项并按 `Tab`,可编辑上下文窗口、最大输出 token、推理档位和图片输入能力。 -会话管理界面默认聚焦当前工作区最近使用的会话;没有历史时聚焦“新建会话”卡片,按 `←` 可移到工作区栏。界面会立即显示上次成功读取的列表,同时核对持久化存储的变化。DSH、Claude 和 Codex 的首屏快照都可跨重启复用,并按内核与存储目录隔离。没有快照时,三种后端都优先加载当前工作区,再补齐其他工作区。Claude 和 Codex 按各自原生的 cwd 范围先请求最近的最多 32 条会话:Claude 的 SDK 默认包含同仓库 Git worktree,Codex 则精确匹配会话 cwd。界面仍按会话自身 cwd 分组。DSH 先解析当前工作区的元数据,即使不足 32 条,也会在读取其他工作区日志前显示;需要深度扫描日志的标题会先显示回退名称,恢复完成后在原行更新。 +会话管理界面默认聚焦当前工作区最近使用的会话;没有历史时聚焦“新建会话”卡片,按 `←` 可移到工作区栏。同一工作区左右切栏或鼠标移入列表会保留会话选择;换工作区时选中该工作区最新的会话,返回之前的工作区时也重新选最新会话,不记忆各工作区的旧选择。界面会立即显示上次成功读取的列表,同时核对持久化存储的变化。DSH、Claude 和 Codex 的首屏快照都可跨重启复用,并按内核与存储目录隔离。没有快照时,三种后端都优先加载当前工作区,再补齐其他工作区。Claude 和 Codex 按各自原生的 cwd 范围先请求最近的最多 32 条会话:Claude 的 SDK 默认包含同仓库 Git worktree,Codex 则精确匹配会话 cwd。界面仍按会话自身 cwd 分组。DSH 先解析当前工作区的元数据,即使不足 32 条,也会在读取其他工作区日志前显示;需要深度扫描日志的标题会先显示回退名称,恢复完成后在原行更新。 使用 DSH 当前的 JSONL 后端时,启动与 `/new` 的初始权限事件只保留在内存中;后续会话活动或显式持久化 flush 才会保存完整日志。尚未落盘的空会话重启时会重新创建。 移除工作区登记后,其历史会话仍可从侧栏的「仅历史」目录进入。 「仅历史」目录只提供编辑和新建会话操作;重命名与移除仅适用于已登记工作区。 diff --git a/docs/interaction.en.md b/docs/interaction.en.md index dd1173528..b662bcf00 100644 --- a/docs/interaction.en.md +++ b/docs/interaction.en.md @@ -352,7 +352,7 @@ brings it back. | Key | Action | | --- | --- | -| `←` `→` | Choose the column that owns the keyboard (rail / sessions); exactly one `❯` is on screen | +| `←` `→` | Choose the column that owns the keyboard (rail / sessions), preserving the selected session in the same workspace; exactly one `❯` is on screen | | `↑` `↓` / `PgUp` `PgDn` / wheel | Move in the rail; move in the session list (row 0 is the new-session card) | | Type | Filter the selected workspace's sessions live (title, directory, branch, model) | | `Enter` | Sessions: enter the row under the cursor (row 0 = start a session in this workspace). Rail: open that workspace's action menu | @@ -363,6 +363,8 @@ brings it back. | `Tab` / `Shift+Tab` | Next / previous source tab (when other agents have sessions) | | `Esc` | Dismiss a notice → clear the filter → leave the screen | +Each workspace starts on its most recent visible session, or the new-session card if it has no matches. Changing columns or moving the mouse into the list preserves the current selection. Changing workspace chooses that workspace's newest session; returning chooses the newest again, without remembering past selections per workspace. A workspace still being loaded updates this default as rows arrive until you select a list row. + The native cursor follows the last-used region, as in the model picker: workspace navigation parks it on the workspace row, session navigation on the session row or new-session card, and typing or clicking the filter on its text caret. Row carets hide after 0.5 seconds at rest; the search caret stays visible. Animation and trail effects come from your terminal settings. Narrow layouts keep the caret on a visible target when the workspace rail is hidden. Arrow and Enter actions keep the same meaning while filtering. The right side of the title row is the **source strip**: `DSH │ Claude Code Codex …`. diff --git a/docs/interaction.md b/docs/interaction.md index 8c5efc458..ea3a03046 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -344,7 +344,7 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 | 按键 | 作用 | | --- | --- | -| `←` `→` | 切换键盘所在栏(工作区栏 / 会话栏),屏幕上永远只有一个 `❯` | +| `←` `→` | 切换键盘所在栏(工作区栏 / 会话栏),同一工作区保留会话选择,屏幕上永远只有一个 `❯` | | `↑` `↓` / `PgUp` `PgDn` / 滚轮 | 在工作区栏移动工作区;在会话栏移动会话(第 0 行是"新建会话"卡片) | | 直接输入 | 实时筛选当前工作区下的会话(标题、目录、分支、模型) | | `Enter` | 会话栏:进入光标所在行(第 0 行 = 在该工作区新建会话);工作区栏:打开该工作区的操作菜单 | @@ -366,6 +366,8 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 外部标签下 `Ctrl+N`、`Ctrl+X`、固定不生效;`Ctrl+L` 改为重新扫描该来源。 - 导入与 `/migrate` 用同一套解析和确定性 id,两个入口导入同一条会话得到的是同一个 DSH 会话。 +每个工作区默认选中当前可见的最新会话,没有匹配会话时落在“新建会话”卡片。左右切栏或鼠标移入列表不会重置选择;换工作区时选中该工作区最新会话,返回之前的工作区时也重新选最新会话,不记忆各工作区的旧选择。尚在加载的工作区会随首批或完整列表更新默认选择,直到你主动选择会话行。 + 原生光标与模型选择器一样跟随最后操作的区域:导航工作区时停在工作区行,导航会话时停在会话行或“新建会话”卡片,输入或点击筛选框时移到文本光标。行光标停稳 0.5 秒后隐藏,搜索光标保持可见;动画与拖尾沿用终端设置。窄屏隐藏工作区栏后,光标会回落到可见位置。筛选期间方向键与 Enter 的作用保持一致。 工作区栏的菜单有四项: diff --git a/guide/dsh-tui-guide/interaction.en.md b/guide/dsh-tui-guide/interaction.en.md index dd1173528..b662bcf00 100644 --- a/guide/dsh-tui-guide/interaction.en.md +++ b/guide/dsh-tui-guide/interaction.en.md @@ -352,7 +352,7 @@ brings it back. | Key | Action | | --- | --- | -| `←` `→` | Choose the column that owns the keyboard (rail / sessions); exactly one `❯` is on screen | +| `←` `→` | Choose the column that owns the keyboard (rail / sessions), preserving the selected session in the same workspace; exactly one `❯` is on screen | | `↑` `↓` / `PgUp` `PgDn` / wheel | Move in the rail; move in the session list (row 0 is the new-session card) | | Type | Filter the selected workspace's sessions live (title, directory, branch, model) | | `Enter` | Sessions: enter the row under the cursor (row 0 = start a session in this workspace). Rail: open that workspace's action menu | @@ -363,6 +363,8 @@ brings it back. | `Tab` / `Shift+Tab` | Next / previous source tab (when other agents have sessions) | | `Esc` | Dismiss a notice → clear the filter → leave the screen | +Each workspace starts on its most recent visible session, or the new-session card if it has no matches. Changing columns or moving the mouse into the list preserves the current selection. Changing workspace chooses that workspace's newest session; returning chooses the newest again, without remembering past selections per workspace. A workspace still being loaded updates this default as rows arrive until you select a list row. + The native cursor follows the last-used region, as in the model picker: workspace navigation parks it on the workspace row, session navigation on the session row or new-session card, and typing or clicking the filter on its text caret. Row carets hide after 0.5 seconds at rest; the search caret stays visible. Animation and trail effects come from your terminal settings. Narrow layouts keep the caret on a visible target when the workspace rail is hidden. Arrow and Enter actions keep the same meaning while filtering. The right side of the title row is the **source strip**: `DSH │ Claude Code Codex …`. diff --git a/guide/dsh-tui-guide/interaction.md b/guide/dsh-tui-guide/interaction.md index 8c5efc458..ea3a03046 100644 --- a/guide/dsh-tui-guide/interaction.md +++ b/guide/dsh-tui-guide/interaction.md @@ -344,7 +344,7 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 | 按键 | 作用 | | --- | --- | -| `←` `→` | 切换键盘所在栏(工作区栏 / 会话栏),屏幕上永远只有一个 `❯` | +| `←` `→` | 切换键盘所在栏(工作区栏 / 会话栏),同一工作区保留会话选择,屏幕上永远只有一个 `❯` | | `↑` `↓` / `PgUp` `PgDn` / 滚轮 | 在工作区栏移动工作区;在会话栏移动会话(第 0 行是"新建会话"卡片) | | 直接输入 | 实时筛选当前工作区下的会话(标题、目录、分支、模型) | | `Enter` | 会话栏:进入光标所在行(第 0 行 = 在该工作区新建会话);工作区栏:打开该工作区的操作菜单 | @@ -366,6 +366,8 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 外部标签下 `Ctrl+N`、`Ctrl+X`、固定不生效;`Ctrl+L` 改为重新扫描该来源。 - 导入与 `/migrate` 用同一套解析和确定性 id,两个入口导入同一条会话得到的是同一个 DSH 会话。 +每个工作区默认选中当前可见的最新会话,没有匹配会话时落在“新建会话”卡片。左右切栏或鼠标移入列表不会重置选择;换工作区时选中该工作区最新会话,返回之前的工作区时也重新选最新会话,不记忆各工作区的旧选择。尚在加载的工作区会随首批或完整列表更新默认选择,直到你主动选择会话行。 + 原生光标与模型选择器一样跟随最后操作的区域:导航工作区时停在工作区行,导航会话时停在会话行或“新建会话”卡片,输入或点击筛选框时移到文本光标。行光标停稳 0.5 秒后隐藏,搜索光标保持可见;动画与拖尾沿用终端设置。窄屏隐藏工作区栏后,光标会回落到可见位置。筛选期间方向键与 Enter 的作用保持一致。 工作区栏的菜单有四项: diff --git a/scripts/verify-session-supervisor.tsx b/scripts/verify-session-supervisor.tsx index d4c38bd03..20987f32b 100644 --- a/scripts/verify-session-supervisor.tsx +++ b/scripts/verify-session-supervisor.tsx @@ -289,6 +289,8 @@ interface StubChannelConfig { readonly cols?: number /** Exercise the normal screen buffer as well as fullscreen. */ readonly inline?: boolean + /** Native backends derive workspace groups without the DSH ledger. */ + readonly backendId?: 'claude' | 'codex' /** * The persistent cache this channel reads. * @@ -367,6 +369,9 @@ function makeChannel(config: StubChannelConfig): StubChannel { cwd: config.cwd, working: false, agentId: 'live-one', + ...(config.backendId === undefined ? {} : { + backendCapabilities: { backendId: config.backendId, backendLabel: config.backendId, commands: [] }, + }), ...(config.registryAbsent === true ? {} : { listWorkspaceRegistry: async () => { if (config.registryRejects === true) throw new Error('workspace service unavailable') @@ -463,6 +468,8 @@ interface SupervisorScreen { cursor: () => { col: number; row: number; visible: boolean } /** Read a display cell without treating CJK text offsets as columns. */ cell: (col: number, row: number) => string + /** Real mouse motion, acknowledged by the row's hover background. */ + hover: (needle: string) => Promise /** One real SGR click on the first occurrence of `needle`. */ click: (needle: string) => Promise /** One real SGR right click on the first occurrence of `needle`. */ @@ -532,6 +539,15 @@ async function mountSupervisor(target: StubChannel): Promise { lines: () => viewportLines(screen), cursor: () => ({ col: screen.buffer.active.cursorX, row: screen.buffer.active.cursorY, visible: cursorVisible }), cell: (col, row) => screen.buffer.active.getLine(screen.buffer.active.baseY + row)?.getCell(col)?.getChars() ?? '', + hover: async (needle) => { + await settled(() => findText(screen, needle) !== null) + const found = findText(screen, needle) + if (found === null) throw new Error(`text not found: ${needle}`) + const background = () => screen.buffer.active.getLine(screen.buffer.active.baseY + found.row)?.getCell(found.col)?.getBgColor() + const before = background() + input.write(`\u001b[<35;${found.col + 1};${found.row + 1}M`) + return settled(() => background() !== before) + }, click: async (needle: string) => { await settled(() => findText(screen, needle) !== null) const found = findText(screen, needle) @@ -589,7 +605,7 @@ for (const inline of [false, true]) { check(`${label}: a hidden rail falls back to the visible filter caret`, await settled(() => searchAt('', 0))) } app.write('\u001b[C') - await settled(() => markerAt('live session')) + await settled(() => markerAt('free session')) for (let step = 0; step < sessions.length && !markerAt('+ New session'); step++) { const previous = app.cursor().row app.write('\u001b[A') @@ -613,6 +629,56 @@ for (const inline of [false, true]) { } } +console.log('pane changes preserve selection; workspace changes choose their newest session') +const workspaceRows = [ + session({ id: 'a-latest', title: { text: 'a latest', source: 'prompt' }, updatedAt: now - 1_000 }), + session({ id: 'a-older', title: { text: 'a older', source: 'prompt' }, updatedAt: now - 2_000 }), + session({ id: 'b-latest', cwd: betaDir, title: { text: 'b latest', source: 'prompt' }, updatedAt: now - 500 }), + session({ id: 'b-older', cwd: betaDir, title: { text: 'b older', source: 'prompt' }, updatedAt: now - 3_000 }), +] +for (const backendId of [undefined, 'claude', 'codex'] as const) { + const label = backendId ?? 'dsh' + const app = await openSupervisor({ registry, cwd: alphaDir, sessions: workspaceRows, backendId }) + const focused = (title: string): boolean => app.lines().some(line => line.includes(title) && line.includes('❯')) + const railFocused = (): boolean => app.lines().some(line => /❯\s+[▣▢]/u.test(line)) + try { + check(`${label}: opening selects the latest session`, await settled(() => focused('a latest'))) + check(`${label}: mouse motion reaches another session row`, await app.hover('a older')) + check(`${label}: mouse motion leaves the initial latest selection intact`, focused('a latest')) + app.write('\u001b[B') + await settled(() => focused('a older')) + app.write('\u001b[D') + await settled(railFocused) + app.write('\u001b[C') + check(`${label}: left/right returns to the selected session`, await settled(() => focused('a older'))) + if (backendId === undefined) { + await app.click('▣ Alpha') + app.write('\u001b[C') + check('reselecting the same workspace retains its session selection', await settled(() => focused('a older'))) + } + app.write('\u001b[D') + await settled(railFocused) + app.write(backendId === undefined ? '\u001b[A' : '\u001b[B') + await settled(() => app.lines().some(line => line.includes('b latest'))) + app.write('\u001b[C') + check(`${label}: another workspace defaults to its latest session`, await settled(() => focused('b latest'))) + app.write('\u001b[B') + await settled(() => focused('b older')) + app.write('\u001b[D') + await settled(railFocused) + app.write(backendId === undefined ? '\u001b[B' : '\u001b[A') + await settled(() => app.lines().some(line => line.includes('a latest'))) + app.write('\u001b[C') + check(`${label}: returning to a workspace resets to latest instead of remembering its old selection`, await settled(() => focused('a latest'))) + app.write('\u001b[A') + await settled(() => focused('+ New session')) + app.write('\u001b[D') + await settled(railFocused) + app.write('\u001b[C') + check(`${label}: an explicitly selected new-session card also survives a pane round trip`, await settled(() => focused('+ New session'))) + } finally { app.close() } +} + // ── snapshot-then-refresh (issue #987) ───────────────────────────────────── // // What a mount paints BEFORE the fresh listing lands. The snapshot lives on @@ -630,6 +696,28 @@ function deferred(): { promise: Promise; resolve: () => void } { return { promise, resolve } } +console.log('a cold workspace chooses its latest session when the listing arrives') +{ + const target = makeChannel({ registry, cwd: alphaDir, cache: cacheCell(workspaceRows.filter(row => (row as { cwd: string }).cwd === alphaDir)) }) + const gate = deferred() + target.plan = { defer: gate.promise, sessions: workspaceRows } + const app = await mountSupervisor(target) + const focused = (title: string): boolean => app.lines().some(line => line.includes(title) && line.includes('❯')) + try { + await settled(() => focused('a latest')) + app.write('\u001b[D') + await settled(() => app.lines().some(line => /❯\s+▣ Alpha/u.test(line))) + app.write('\u001b[A') + await settled(() => app.lines().some(line => line.includes('Sessions in Beta'))) + app.write('\u001b[C') + await settled(() => focused('+ New session')) + gate.resolve() + check('a switched workspace picks its latest session after the cold listing completes', await settled(() => focused('b latest'))) + app.write('\r') + check('Enter resumes that newest session rather than starting a new one', await settled(() => app.calls.includes('resumeTo:b-latest'))) + } finally { gate.resolve(); app.close() } +} + /** The same listing with one title renamed on disk (same id): a carried-over * snapshot shows the OLD title, the fresh listing corrects it. */ const renamedSessions = [ @@ -1403,11 +1491,13 @@ console.log('the new-session card is a row in the cursor model:') stdin.write('\u001b[C') await settled(() => true) check( - '→ lands on the first session, not on the card', - await settled(() => sessionRowFocus('live session') && !cardFocus()), - `card=${cardFocus()} session=${sessionRowFocus('live session')}`, + '→ restores the selected latest session, not the card', + await settled(() => sessionRowFocus('free session') && !cardFocus()), + `card=${cardFocus()} session=${sessionRowFocus('free session')}`, ) stdin.write('\u001b[A') +await settled(() => sessionRowFocus('live session')) +stdin.write('\u001b[A') check( '↑ puts the cursor on the card, and the first session stops being selected', await settled(() => cardFocus() && !sessionRowFocus('live session')), @@ -1563,15 +1653,15 @@ console.log('Enter acts on the row the filter left under the cursor') { const app = await openSupervisor({ registry, cwd: alphaDir }) await settled(() => app.lines().join('\n').includes('Sessions in Alpha')) - // → into the session column, where the cursor lands on the ATTACHED session - // (the second row), not on the first. + // Explicitly select the older attached row before filtering it away. app.write('\u001b[C') - await settled(() => true) const cursorOn = (title: string): boolean => app.lines().some(line => line.includes(title) && line.includes('❯')) + await settled(() => cursorOn('free session')) + app.write('\u001b[A') await settled(() => cursorOn('live session')) check( - 'the cursor starts on the attached session', + 'explicit navigation selects the older attached session', cursorOn('live session') && !cursorOn('free session'), app.lines().join('\n'), ) @@ -1730,8 +1820,6 @@ console.log('a refused open shows its REASON on this screen (#939)') await settled(() => app.lines().join('\n').includes('free session'), { timeoutMs: 6_000 }) app.write('\x1b[C') // → the list pane await sleep(50) // 固定窗:pacing 按键步间:焦点切换无可观测锚点 - app.write('\x1b[B') // past the new-session card onto the first session - await sleep(50) // 固定窗:pacing 按键步间:焦点切换无可观测锚点 app.write('\r') const joined = (): string => app.lines().map(line => line.trim()).join(' ') const flat = (): string => joined().replace(/\s+/gu, '') diff --git a/src/screens/sessionSupervisor/useSessionSupervisor.ts b/src/screens/sessionSupervisor/useSessionSupervisor.ts index eca11a52c..124228f57 100644 --- a/src/screens/sessionSupervisor/useSessionSupervisor.ts +++ b/src/screens/sessionSupervisor/useSessionSupervisor.ts @@ -265,14 +265,26 @@ export function useSessionSupervisor(input: SessionSupervisorInput) { const [selectedPath, setSelectedPath] = useState(undefined) /** True once the user picked a rail row by hand; see the selection effect. */ const [selectionManual, setSelectionManual] = useState(false) - const recentSessionId = useMemo(() => { + /** The rail row whose sessions the pane is showing (or the fallback group). */ + const selected = useMemo(() => { + const registered = railEntries.find(entry => + entry.from === 'registry' && selectedPath !== undefined && samePath(entry.path, selectedPath)) + if (registered !== undefined) return registered + if (selectedUnregisteredId !== undefined) return railEntries.find(entry => entry.id === selectedUnregisteredId) + return railEntries.find(entry => samePath(entry.path, channel.cwd)) ?? railEntries[0] + }, [railEntries, selectedPath, selectedUnregisteredId, channel.cwd]) + + // Recency chooses the default even when an older live row sorts above it. + const mostRecentIn = useCallback((path: string): string | undefined => { + const needle = query.trim().toLowerCase() let recent: SessionSummary | undefined - for (const session of listedSessions) { - if (samePath(session.cwd, channel.cwd) + for (const session of groupedEntries.get(path) ?? []) { + if (sessionMatchesQuery(session, needle) && (recent === undefined || session.updatedAt > recent.updatedAt)) recent = session } return recent?.id - }, [listedSessions, channel.cwd]) + }, [groupedEntries, query]) + const recentSessionId = useMemo(() => selected === undefined ? undefined : mostRecentIn(selected.path), [selected?.path, mostRecentIn]) /** * The session column's cursor, as ONE fact. * @@ -300,7 +312,7 @@ export function useSessionSupervisor(input: SessionSupervisorInput) { initialFocusPending.current = false setFocusSessionIdState(id) }, []) - // Correct the default as the first listing arrives, until navigation owns it. + // Keep the workspace's default current until the user chooses a list row. React.useEffect(() => { if (!initialFocusPending.current) return setFocusSessionIdState(recentSessionId) @@ -485,15 +497,6 @@ export function useSessionSupervisor(input: SessionSupervisorInput) { setRailFocus(current => Math.min(current, Math.max(0, railEntries.length - 1))) }, [railEntries.length]) - /** The rail row whose sessions the pane is showing (or the fallback group). */ - const selected = useMemo(() => { - const registered = railEntries.find(entry => - entry.from === 'registry' && selectedPath !== undefined && samePath(entry.path, selectedPath)) - if (registered !== undefined) return registered - if (selectedUnregisteredId !== undefined) return railEntries.find(entry => entry.id === selectedUnregisteredId) - return railEntries[0] - }, [railEntries, selectedPath, selectedUnregisteredId]) - /** * Sessions whose recorded cwd is the selected workspace, minus the search * filter. Live sessions in this workspace sort above stopped ones, then by @@ -543,22 +546,13 @@ export function useSessionSupervisor(input: SessionSupervisorInput) { [visibleSessions], ) - /** - * Enter the session column: land the cursor on the session this terminal is - * attached to (that is the one the user most likely means), else on the top - * row — which is the new-session card when the list sorted its live rows - * lower. A cursor that stayed put while the list scrolled elsewhere would act - * on a row the user never looked at. - */ + /** Enter the session column without changing its selected row. */ const activateList = useCallback((): void => { setActivePane('list') - const current = visibleSessions.find(session => liveStateOf(session.id)?.current === true) - setFocusSessionId(current?.id) - }, [liveStateOf, visibleSessions, setFocusSessionId]) + }, []) /** Enter the workspace column. */ const activateRail = useCallback((): void => { - initialFocusPending.current = false setActivePane('rail') }, []) @@ -650,8 +644,13 @@ export function useSessionSupervisor(input: SessionSupervisorInput) { setSelectedPath(entry.from === 'registry' ? entry.path : undefined) setSelectedUnregisteredId(entry.from === 'unregistered' ? entry.id : undefined) setSelectionManual(true) - setFocusSessionId(undefined) - }, [setFocusSessionId]) + if (selected === undefined || !samePath(selected.path, entry.path)) { + // One selection belongs to the current workspace, without a per-workspace + // memory. A cold group's default follows incoming rows until list movement. + initialFocusPending.current = true + setFocusSessionIdState(mostRecentIn(entry.path)) + } + }, [selected, mostRecentIn]) /** * Mount a session, refusing one another terminal holds. From 0dceae8f2817c054f6b08ce5f6e658e6ca28ef1e Mon Sep 17 00:00:00 2001 From: CikeSeven Date: Sun, 11 Oct 2026 04:55:34 +0800 Subject: [PATCH 3/3] fix(sessions): keep untouched defaults through refresh Do not stop following the workspace default merely because a listing is idle. Refreshing an initially empty workspace now selects its newest visible session until the user chooses a row. Cover repeated refreshes, Enter on the refreshed default, and preservation of explicitly selected sessions and new-session cards. --- docs/interaction.en.md | 2 +- docs/interaction.md | 2 +- guide/dsh-tui-guide/interaction.en.md | 2 +- guide/dsh-tui-guide/interaction.md | 2 +- scripts/verify-session-supervisor.tsx | 46 +++++++++++++++++++ .../sessionSupervisor/useSessionSupervisor.ts | 3 +- 6 files changed, 51 insertions(+), 6 deletions(-) diff --git a/docs/interaction.en.md b/docs/interaction.en.md index b662bcf00..0b070a12b 100644 --- a/docs/interaction.en.md +++ b/docs/interaction.en.md @@ -363,7 +363,7 @@ brings it back. | `Tab` / `Shift+Tab` | Next / previous source tab (when other agents have sessions) | | `Esc` | Dismiss a notice → clear the filter → leave the screen | -Each workspace starts on its most recent visible session, or the new-session card if it has no matches. Changing columns or moving the mouse into the list preserves the current selection. Changing workspace chooses that workspace's newest session; returning chooses the newest again, without remembering past selections per workspace. A workspace still being loaded updates this default as rows arrive until you select a list row. +Each workspace starts on its most recent visible session, or the new-session card if it has no matches. Changing columns or moving the mouse into the list preserves the current selection. Changing workspace chooses that workspace's newest session; returning chooses the newest again, without remembering past selections per workspace. Until you choose a list row, partial results, complete reads, and later refreshes keep following the newest visible session. After you select a session or the new-session card, refreshes preserve that choice. The native cursor follows the last-used region, as in the model picker: workspace navigation parks it on the workspace row, session navigation on the session row or new-session card, and typing or clicking the filter on its text caret. Row carets hide after 0.5 seconds at rest; the search caret stays visible. Animation and trail effects come from your terminal settings. Narrow layouts keep the caret on a visible target when the workspace rail is hidden. Arrow and Enter actions keep the same meaning while filtering. diff --git a/docs/interaction.md b/docs/interaction.md index ea3a03046..84a07bbb1 100644 --- a/docs/interaction.md +++ b/docs/interaction.md @@ -366,7 +366,7 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 外部标签下 `Ctrl+N`、`Ctrl+X`、固定不生效;`Ctrl+L` 改为重新扫描该来源。 - 导入与 `/migrate` 用同一套解析和确定性 id,两个入口导入同一条会话得到的是同一个 DSH 会话。 -每个工作区默认选中当前可见的最新会话,没有匹配会话时落在“新建会话”卡片。左右切栏或鼠标移入列表不会重置选择;换工作区时选中该工作区最新会话,返回之前的工作区时也重新选最新会话,不记忆各工作区的旧选择。尚在加载的工作区会随首批或完整列表更新默认选择,直到你主动选择会话行。 +每个工作区默认选中当前可见的最新会话,没有匹配会话时落在“新建会话”卡片。左右切栏或鼠标移入列表不会重置选择;换工作区时选中该工作区最新会话,返回之前的工作区时也重新选最新会话,不记忆各工作区的旧选择。尚未主动选择列表行时,首批结果、完整读取和后续刷新都会跟随最新可见会话;主动选择会话行或“新建会话”后,刷新保持该选择。 原生光标与模型选择器一样跟随最后操作的区域:导航工作区时停在工作区行,导航会话时停在会话行或“新建会话”卡片,输入或点击筛选框时移到文本光标。行光标停稳 0.5 秒后隐藏,搜索光标保持可见;动画与拖尾沿用终端设置。窄屏隐藏工作区栏后,光标会回落到可见位置。筛选期间方向键与 Enter 的作用保持一致。 diff --git a/guide/dsh-tui-guide/interaction.en.md b/guide/dsh-tui-guide/interaction.en.md index b662bcf00..0b070a12b 100644 --- a/guide/dsh-tui-guide/interaction.en.md +++ b/guide/dsh-tui-guide/interaction.en.md @@ -363,7 +363,7 @@ brings it back. | `Tab` / `Shift+Tab` | Next / previous source tab (when other agents have sessions) | | `Esc` | Dismiss a notice → clear the filter → leave the screen | -Each workspace starts on its most recent visible session, or the new-session card if it has no matches. Changing columns or moving the mouse into the list preserves the current selection. Changing workspace chooses that workspace's newest session; returning chooses the newest again, without remembering past selections per workspace. A workspace still being loaded updates this default as rows arrive until you select a list row. +Each workspace starts on its most recent visible session, or the new-session card if it has no matches. Changing columns or moving the mouse into the list preserves the current selection. Changing workspace chooses that workspace's newest session; returning chooses the newest again, without remembering past selections per workspace. Until you choose a list row, partial results, complete reads, and later refreshes keep following the newest visible session. After you select a session or the new-session card, refreshes preserve that choice. The native cursor follows the last-used region, as in the model picker: workspace navigation parks it on the workspace row, session navigation on the session row or new-session card, and typing or clicking the filter on its text caret. Row carets hide after 0.5 seconds at rest; the search caret stays visible. Animation and trail effects come from your terminal settings. Narrow layouts keep the caret on a visible target when the workspace rail is hidden. Arrow and Enter actions keep the same meaning while filtering. diff --git a/guide/dsh-tui-guide/interaction.md b/guide/dsh-tui-guide/interaction.md index ea3a03046..84a07bbb1 100644 --- a/guide/dsh-tui-guide/interaction.md +++ b/guide/dsh-tui-guide/interaction.md @@ -366,7 +366,7 @@ Bracketed paste(右键或终端原生粘贴)保留普通文本与换行。 - 外部标签下 `Ctrl+N`、`Ctrl+X`、固定不生效;`Ctrl+L` 改为重新扫描该来源。 - 导入与 `/migrate` 用同一套解析和确定性 id,两个入口导入同一条会话得到的是同一个 DSH 会话。 -每个工作区默认选中当前可见的最新会话,没有匹配会话时落在“新建会话”卡片。左右切栏或鼠标移入列表不会重置选择;换工作区时选中该工作区最新会话,返回之前的工作区时也重新选最新会话,不记忆各工作区的旧选择。尚在加载的工作区会随首批或完整列表更新默认选择,直到你主动选择会话行。 +每个工作区默认选中当前可见的最新会话,没有匹配会话时落在“新建会话”卡片。左右切栏或鼠标移入列表不会重置选择;换工作区时选中该工作区最新会话,返回之前的工作区时也重新选最新会话,不记忆各工作区的旧选择。尚未主动选择列表行时,首批结果、完整读取和后续刷新都会跟随最新可见会话;主动选择会话行或“新建会话”后,刷新保持该选择。 原生光标与模型选择器一样跟随最后操作的区域:导航工作区时停在工作区行,导航会话时停在会话行或“新建会话”卡片,输入或点击筛选框时移到文本光标。行光标停稳 0.5 秒后隐藏,搜索光标保持可见;动画与拖尾沿用终端设置。窄屏隐藏工作区栏后,光标会回落到可见位置。筛选期间方向键与 Enter 的作用保持一致。 diff --git a/scripts/verify-session-supervisor.tsx b/scripts/verify-session-supervisor.tsx index 20987f32b..882346db4 100644 --- a/scripts/verify-session-supervisor.tsx +++ b/scripts/verify-session-supervisor.tsx @@ -696,6 +696,52 @@ function deferred(): { promise: Promise; resolve: () => void } { return { promise, resolve } } +console.log('an idle empty workspace follows refreshed defaults until the user selects a row') +{ + const target = makeChannel({ registry, cwd: alphaDir, sessions: workspaceRows.filter(row => (row as { cwd: string }).cwd === alphaDir) }) + const app = await mountSupervisor(target) + const focused = (title: string): boolean => app.lines().some(line => line.includes(title) && line.includes('❯')) + const later = session({ id: 'b-later', cwd: betaDir, title: { text: 'b later', source: 'prompt' }, updatedAt: now + 1_000 }) + const newest = session({ id: 'b-newest', cwd: betaDir, title: { text: 'b newest', source: 'prompt' }, updatedAt: now + 2_000 }) + try { + await settled(() => target.landed === 1 && focused('a latest')) + app.write('\u001b[D') + await settled(() => app.lines().some(line => /❯\s+▣ Alpha/u.test(line))) + app.write('\u001b[A') + await settled(() => app.lines().some(line => line.includes('Sessions in Beta'))) + app.write('\u001b[C') + await settled(() => focused('+ New session')) + + target.plan = { sessions: workspaceRows } + app.write('\u000c') + await settled(() => target.landed === 2 && app.lines().some(line => line.includes('b latest'))) + check('an idle empty workspace picks the newest session after Ctrl+L', await settled(() => focused('b latest')), app.lines().join('\n')) + target.plan = { sessions: [...workspaceRows, later] } + app.write('\u000c') + await settled(() => target.landed === 3 && app.lines().some(line => line.includes('b later'))) + check('refresh continues following the default without an explicit row choice', await settled(() => focused('b later'))) + app.write('\r') + check('Enter opens the refreshed default instead of creating a session', await settled(() => app.calls.includes('resumeTo:b-later'))) + + app.write('\u001b[B') + await settled(() => focused('b latest')) + target.plan = { sessions: [...workspaceRows, later, newest] } + app.write('\u000c') + await settled(() => target.landed === 4 && app.lines().some(line => line.includes('b newest'))) + check('refresh preserves an explicitly selected session', await settled(() => focused('b latest'))) + + for (let step = 0; step < workspaceRows.length && !focused('+ New session'); step++) { + const before = app.lines().findIndex(line => line.includes('❯')) + app.write('\u001b[A') + await settled(() => app.lines().findIndex(line => line.includes('❯')) !== before) + } + target.plan = { sessions: [...workspaceRows, later, newest, session({ id: 'b-extra', cwd: betaDir, title: { text: 'b extra', source: 'prompt' }, updatedAt: now + 3_000 })] } + app.write('\u000c') + await settled(() => target.landed === 5 && app.lines().some(line => line.includes('b extra'))) + check('refresh preserves an explicitly selected new-session card', await settled(() => focused('+ New session'))) + } finally { app.close() } +} + console.log('a cold workspace chooses its latest session when the listing arrives') { const target = makeChannel({ registry, cwd: alphaDir, cache: cacheCell(workspaceRows.filter(row => (row as { cwd: string }).cwd === alphaDir)) }) diff --git a/src/screens/sessionSupervisor/useSessionSupervisor.ts b/src/screens/sessionSupervisor/useSessionSupervisor.ts index 124228f57..ccf2aaf35 100644 --- a/src/screens/sessionSupervisor/useSessionSupervisor.ts +++ b/src/screens/sessionSupervisor/useSessionSupervisor.ts @@ -316,8 +316,7 @@ export function useSessionSupervisor(input: SessionSupervisorInput) { React.useEffect(() => { if (!initialFocusPending.current) return setFocusSessionIdState(recentSessionId) - if (!refreshing) initialFocusPending.current = false - }, [recentSessionId, refreshing]) + }, [recentSessionId]) /** * Which column owns the keyboard, and therefore which column draws the `❯` * cursor. Exactly one at a time: two cursors mean "where does Enter go?" has