mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
fix(cli): stabilize thinking block height, replace transcript overlay with inline Ctrl+O toggle (#8077)
* fix(cli): hide streaming thinking preview, rebind Ctrl+O to inline fullDetail toggle The streaming thinking block showed a 4-line preview that varied in height due to empty lines in the model's reasoning output, causing constant page reflow and flicker during generation. Changes: - ThinkBody now renders nothing when collapsed (both streaming and committed), keeping the block at a stable 1-line header height. - Ctrl+O now toggles inline fullDetail mode (like Claude Code): all thinking blocks, tool groups, and tool results expand/collapse in the main conversation view — no alternate-screen overlay. - Alt+T preserved as hidden shortcut (same toggle, not shown in UI). - MainContent passes fullDetail to HistoryItemDisplay via the existing ThoughtExpandedContext, so the toggle works in both VP and Static rendering paths. - Removed TranscriptView overlay rendering, transcriptItems memo, StreamingContext import, and EMPTY_HISTORY_ITEMS constant. - Removed dead code: tailVisualLines, grow-only height tracker, MAX_STREAMING_THINKING_VISUAL_LINES, openTranscript callback. * refactor(cli): remove orphaned transcript full-detail infrastructure (#8077) * fix(cli): update Ctrl+O help text and add thinking-expansion integration test (#8077) * fix(cli): update docs, help text, and remove transcript dead code (#8077) * fix(cli): strengthen Ctrl+O full-detail tests and refresh stale docs (#8077) * fix(cli): address review feedback on test isolation, dead i18n keys, and missing negative case (#8077) * fix(cli): add M1 mutation-killing assertion to Ctrl+O test (#8077) The existing test only asserted clearTerminal (a refreshStatic side effect) and never verified the setThoughtExpanded state flip. Under the vi.mock('ink') harness the mocked App never re-renders from a directly-called handler, so a behavioural allExpanded assertion is not possible. Add a structural guard on the handler source that fails when setThoughtExpanded is removed (mutation M1 verified). * fix(cli): strengthen Ctrl+O toggle assertion and document non-VP redraw (#8077) Tighten the structural M1 assertion from .toContain to a regex that matches the (prev) => !prev updater pattern, catching mutations like (prev) => true that the old check would miss. Document the non-VP scrollback redraw in keyboard-shortcuts.md per maintainer request. --------- Co-authored-by: 秦奇 <[email protected]> Co-authored-by: Qwen Code Bot <[email protected]> Co-authored-by: Qwen Code Autofix <[email protected]> Co-authored-by: qwen-code-ci-bot <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]> Co-authored-by: Qwen-Coder <[email protected]> Co-authored-by: qwen-code-dev-bot <[email protected]>
This commit is contained in:
35 files changed
+197
-1097
No files matched your search
@@ -1,5 +1,7 @@
|
||||
# 设计方案:Ctrl+O 行为重构 —— 对齐 Claude Code 的 Transcript 模型
|
||||
|
||||
> **⚠️ 已被取代(superseded)**:本文档记录的 **`TranscriptView` + `AlternateScreen` 全详情冻结快照屏** 方案已在后续重构(PR #8077)中**移除**。当前实现中,`Ctrl+O`(与 `Alt+T` 共用 `Command.TOGGLE_THINKING_EXPANDED`)不再打开独立的 transcript 屏,而是**就地切换 full-detail 模式**:通过 `ThoughtExpandedContext.allExpanded` 由 `MainContent` 以 `fullDetail` 下传给每个 `HistoryItemDisplay`,在主视图内联展开所有思考块与工具组(并解除工具结果截断),再按一次收起。下文凡描述独立 transcript 屏与 alt-screen 的章节(如 §3.2、§4.2–§4.5、§9 的栈式 commit 拆分)仅作**历史记录**保留,与现行代码不符。
|
||||
|
||||
- 分支:`feat/ctrl-o-detail-expand`
|
||||
- worktree:`<worktree-path>`
|
||||
- 状态:**实现进行中——本文档为当前 PR 实现的验收基线**(非 docs-only;当前 PR 已含实现文件改动)
|
||||
|
||||
@@ -125,7 +125,7 @@ Settings are organized into categories. Most settings should be placed within th
|
||||
| `ui.showCitations` | boolean | Show citations for generated text in the chat. | `false` |
|
||||
| `ui.history.collapseOnResume` | boolean | Whether to collapse history by default when resuming a session. Can be toggled via `/history collapse-on-resume` and `/history expand-on-resume`. | `false` |
|
||||
| `ui.history.collapsePreviewCount` | number | Number of most recent user turns to keep visible when `ui.history.collapseOnResume` is enabled. `0` collapses all restored history by default; `-1` shows all restored history. | `0` |
|
||||
| `ui.compactMode` | boolean | Retired in the terminal UI. The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to open the full-detail transcript instead of toggling a mode. Still honored by the web shell. | `false` |
|
||||
| `ui.compactMode` | boolean | Retired in the terminal UI. The CLI now always shows the compact, type-based tool view in the main transcript; press `Ctrl+O` to toggle expanded detail mode (expand or collapse all thinking blocks and tool outputs inline) instead of toggling a mode. Still honored by the web shell. | `false` |
|
||||
| `ui.shellOutputMaxLines` | number | Max number of shell output lines shown inline. Set to `0` to disable the cap and show full output. Hidden lines are surfaced via the `+N lines` indicator. Errors, `!`-prefix user-initiated commands, confirming tools, and focused embedded shells always show full output. | `5` |
|
||||
| `ui.enableWelcomeBack` | boolean | Show welcome back dialog when returning to a project with conversation history. When enabled, Qwen Code will automatically detect if you're returning to a project with a previously generated project summary (`.qwen/PROJECT_SUMMARY.md`) and show a dialog allowing you to continue your previous conversation or start fresh. If you choose **Start new chat session**, that choice is remembered for the current project until the project summary changes. This feature integrates with the `/summary` command and quit confirmation dialog. | `true` |
|
||||
| `ui.accessibility.enableLoadingPhrases` | boolean | Enable loading phrases (disable for accessibility). | `true` |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Tool-Use Summaries
|
||||
|
||||
Qwen Code can generate a short, git-commit-subject-style label after each tool batch completes, summarizing what the batch accomplished. The label appears inline: for a completed tool group in the main view it replaces the generic `Tool × N` header; when the group is force-expanded (in the `Ctrl+O` full-detail transcript, or for error / user-initiated batches) it appears as a dim `● <label>` line below the group.
|
||||
Qwen Code can generate a short, git-commit-subject-style label after each tool batch completes, summarizing what the batch accomplished. The label appears inline: for a completed tool group in the main view it replaces the generic `Tool × N` header; when the group is force-expanded (in `Ctrl+O` expanded detail mode, or for error / user-initiated batches) it appears as a dim `● <label>` line below the group.
|
||||
|
||||
This is a UX aid for parallel tool calls: when the model fans out into several `Read` + `Grep` + `Bash` calls at once, the summary tells you the intent at a glance instead of forcing you to scan the tool list.
|
||||
|
||||
@@ -18,11 +18,11 @@ In the main transcript, a completed collapsible batch folds into a single labele
|
||||
╰──────────────────────────────────────────────╯
|
||||
```
|
||||
|
||||
The full per-tool output is a keystroke away: press `Ctrl+O` to open the full-detail transcript.
|
||||
The full per-tool output is a keystroke away: press `Ctrl+O` to toggle expanded detail mode.
|
||||
|
||||
### Full-detail transcript (`Ctrl+O`) and force-expanded groups
|
||||
### Expanded detail mode (`Ctrl+O`) and force-expanded groups
|
||||
|
||||
When a group is force-expanded — in the `Ctrl+O` transcript, or for error / user-initiated batches in the main view — each tool renders individually and the summary appears as a dim badge line below the group:
|
||||
When a group is force-expanded — in `Ctrl+O` expanded detail mode, or for error / user-initiated batches in the main view — each tool renders individually and the summary appears as a dim badge line below the group:
|
||||
|
||||
```
|
||||
╭──────────────────────────────────────────────╮
|
||||
@@ -122,7 +122,7 @@ These settings can be configured in `settings.json`:
|
||||
|
||||
Three points that tend to trip up a first read of this feature:
|
||||
|
||||
1. **One generation per batch, shared by both display modes.** The fast-model call happens exactly once in `handleCompletedTools` when a tool batch finalizes. Opening the `Ctrl+O` transcript afterwards does **not** trigger a new call — both the main view and the full-detail transcript read from the same `tool_use_summary` history entry that was captured the first time.
|
||||
1. **One generation per batch, shared by both display modes.** The fast-model call happens exactly once in `handleCompletedTools` when a tool batch finalizes. Toggling `Ctrl+O` expanded detail mode afterwards does **not** trigger a new call — both the collapsed and the expanded rendering read from the same `tool_use_summary` history entry that was captured the first time.
|
||||
2. **No backfill on toggle or on session resume.** A `tool_group` that completed before the feature was enabled (or before you flipped the setting on, or in a resumed session — `ChatRecordingService` does not persist summary entries) will never get a label. There is no "sweep existing history" pass. If you turn this setting on mid-session, only _future_ batches will show a label; older groups keep the default rendering with no indicator that a label is missing.
|
||||
3. **Main-agent batches only.** The trigger lives in the main session's turn loop (`useGeminiStream`), so:
|
||||
- ✅ Shell, MCP, file operations, and the `Task` / subagent tool _call itself_ (as it appears in the main batch) are summarized.
|
||||
@@ -132,7 +132,7 @@ Three points that tend to trip up a first read of this feature:
|
||||
|
||||
## Display behavior
|
||||
|
||||
The main view already folds a completed collapsible batch into a single labeled row (`✓ Read 4 text files`) — the summary does the work of the old per-tool list. For full per-tool detail, press `Ctrl+O` to open the full-detail transcript, where each tool renders individually with the summary as a trailing `● <label>` line below the group.
|
||||
The main view already folds a completed collapsible batch into a single labeled row (`✓ Read 4 text files`) — the summary does the work of the old per-tool list. For full per-tool detail, press `Ctrl+O` to toggle expanded detail mode, where each tool renders individually with the summary as a trailing `● <label>` line below the group.
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -170,5 +170,5 @@ If you do not want the extra cost, turn the feature off via `experimental.emitTo
|
||||
|
||||
## Related
|
||||
|
||||
- [Full-detail transcript](../configuration/settings#ui) — press `Ctrl+O` to open the frozen full-detail transcript; the summary replaces the generic tool-group header for completed groups in the main view.
|
||||
- [Expanded detail mode](../configuration/settings#ui) — press `Ctrl+O` to expand all tool outputs inline; the summary replaces the generic tool-group header for completed groups in the main view.
|
||||
- [Followup Suggestions](./followup-suggestions) — another fast-model-driven UX enhancement that shares the same `fastModel` setting.
|
||||
@@ -10,7 +10,7 @@ This document lists the available keyboard shortcuts in Qwen Code.
|
||||
| `Ctrl+C` | Cancel the ongoing request and clear the input. Press twice to exit the application. |
|
||||
| `Ctrl+D` | Exit the application if the input is empty. Press twice to confirm. |
|
||||
| `Ctrl+L` | Clear the screen. |
|
||||
| `Ctrl+O` | Open/close the full-detail transcript view (a scrollable, frozen snapshot showing every tool's complete output and full thinking). Press again, or `Esc`/`q`, to close. |
|
||||
| `Ctrl+O` | Toggle expanded detail mode: expand or collapse all thinking blocks and tool outputs inline. Press again to collapse. When `ui.useTerminalBuffer` is off, toggling redraws the full conversation with untruncated output into terminal scrollback. |
|
||||
| `Ctrl+S` | Stashes non-empty input for the current project and restores it on the next launch. With empty input, allows long responses to print fully, disabling truncation. Use your terminal's scrollback to view the entire output. |
|
||||
| `Ctrl+T` | Toggle the display of tool descriptions. |
|
||||
| `Ctrl+B` | While a foreground shell command is running: promote it to a background task. The child keeps running, the agent's turn unblocks, and the shell appears in `/tasks` + the Background tasks dialog. No-op when no shell is executing — Ctrl+B then falls through to its prompt-area binding (cursor-left). |
|
||||
|
||||
@@ -82,9 +82,6 @@ export enum Command {
|
||||
// Thinking expansion
|
||||
TOGGLE_THINKING_EXPANDED = 'toggleThinkingExpanded',
|
||||
|
||||
// Transcript full-detail screen (Ctrl+O)
|
||||
TOGGLE_TRANSCRIPT = 'toggleTranscript',
|
||||
|
||||
// Scroll commands
|
||||
SCROLL_UP = 'scrollUp',
|
||||
SCROLL_DOWN = 'scrollDown',
|
||||
@@ -266,11 +263,11 @@ export const defaultKeyBindings: KeyBindingConfig = {
|
||||
[Command.EXPAND_SUGGESTION]: [{ key: 'right' }],
|
||||
[Command.COLLAPSE_SUGGESTION]: [{ key: 'left' }],
|
||||
|
||||
// Thinking expansion
|
||||
[Command.TOGGLE_THINKING_EXPANDED]: [{ key: 't', meta: true }],
|
||||
|
||||
// Transcript full-detail screen
|
||||
[Command.TOGGLE_TRANSCRIPT]: [{ key: 'o', ctrl: true }],
|
||||
// Thinking expansion (Ctrl+O primary, Alt+T legacy)
|
||||
[Command.TOGGLE_THINKING_EXPANDED]: [
|
||||
{ key: 'o', ctrl: true },
|
||||
{ key: 't', meta: true },
|
||||
],
|
||||
|
||||
// Scroll commands
|
||||
[Command.SCROLL_UP]: [{ key: 'up', shift: true }],
|
||||
|
||||
@@ -58,7 +58,7 @@ export default {
|
||||
'to search history': "per cercar a l'historial",
|
||||
'to paste images': 'per enganxar imatges',
|
||||
'for external editor': 'per a editor extern',
|
||||
'to view transcript': 'per veure la transcripció',
|
||||
'to expand details': 'per expandir els detalls',
|
||||
'Jump through words in the input': "Salta entre paraules a l'entrada",
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
"Tanca els diàlegs, cancel·la les peticions o surt de l'aplicació",
|
||||
@@ -252,9 +252,6 @@ export default {
|
||||
'Unknown Step': 'Pas desconegut',
|
||||
'Esc to close': 'Esc per tancar',
|
||||
Transcript: 'Transcripció',
|
||||
'to close': 'per tancar',
|
||||
'to scroll': 'per desplaçar',
|
||||
'Failed to render transcript.': 'Error en renderitzar la transcripció.',
|
||||
'Read {{count}} file': 'Ha llegit {{count}} fitxer',
|
||||
'Read {{count}} files': 'Ha llegit {{count}} fitxers',
|
||||
'Reading {{count}} file': 'Llegint {{count}} fitxer',
|
||||
|
||||
@@ -232,9 +232,6 @@ export default {
|
||||
'Unknown Step': 'Unbekannter Schritt',
|
||||
'Esc to close': 'Esc zum Schließen',
|
||||
Transcript: 'Transkript',
|
||||
'to close': 'zum Schließen',
|
||||
'to scroll': 'zum Scrollen',
|
||||
'Failed to render transcript.': 'Transkript konnte nicht gerendert werden.',
|
||||
'Read {{count}} file': '{{count}} Datei gelesen',
|
||||
'Read {{count}} files': '{{count}} Dateien gelesen',
|
||||
'Reading {{count}} file': 'Lese {{count}} Datei',
|
||||
@@ -1911,7 +1908,7 @@ export default {
|
||||
'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.',
|
||||
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
|
||||
'(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Ctrl+C zum Beenden)\n',
|
||||
'to view transcript': 'zum Anzeigen des Transkripts',
|
||||
'to expand details': 'zum Erweitern der Details',
|
||||
'Switch to plan mode or exit plan mode':
|
||||
'In den Plan-Modus wechseln oder den Plan-Modus verlassen',
|
||||
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':
|
||||
|
||||
@@ -276,7 +276,7 @@ export default {
|
||||
'to search history': 'to search history',
|
||||
'to paste images': 'to paste images',
|
||||
'for external editor': 'for external editor',
|
||||
'to view transcript': 'to view transcript',
|
||||
'to expand details': 'to expand details',
|
||||
'Jump through words in the input': 'Jump through words in the input',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'Close dialogs, cancel requests, or quit application',
|
||||
@@ -506,9 +506,6 @@ export default {
|
||||
'Unknown Step': 'Unknown Step',
|
||||
'Esc to close': 'Esc to close',
|
||||
Transcript: 'Transcript',
|
||||
'to close': 'to close',
|
||||
'to scroll': 'to scroll',
|
||||
'Failed to render transcript.': 'Failed to render transcript.',
|
||||
'Read {{count}} file': 'Read {{count}} file',
|
||||
'Read {{count}} files': 'Read {{count}} files',
|
||||
'Reading {{count}} file': 'Reading {{count}} file',
|
||||
|
||||
@@ -256,9 +256,6 @@ export default {
|
||||
'Unknown Step': 'Étape inconnue',
|
||||
'Esc to close': 'Esc pour fermer',
|
||||
Transcript: 'Transcription',
|
||||
'to close': 'pour fermer',
|
||||
'to scroll': 'pour défiler',
|
||||
'Failed to render transcript.': 'Échec du rendu de la transcription.',
|
||||
'Read {{count}} file': 'Lu {{count}} fichier',
|
||||
'Read {{count}} files': 'Lu {{count}} fichiers',
|
||||
'Reading {{count}} file': 'Lecture de {{count}} fichier',
|
||||
@@ -2045,7 +2042,7 @@ export default {
|
||||
'Afficher le détail de l’utilisation du contexte par élément.',
|
||||
|
||||
// === Missing key backfill ===
|
||||
'to view transcript': 'pour voir la transcription',
|
||||
'to expand details': 'pour développer les détails',
|
||||
'The name of the extension to update.':
|
||||
"Le nom de l'extension à mettre à jour.",
|
||||
'Session (temporary)': 'Session (temporaire)',
|
||||
|
||||
@@ -208,9 +208,6 @@ export default {
|
||||
'Unknown Step': '不明なステップ',
|
||||
'Esc to close': 'Esc で閉じる',
|
||||
Transcript: 'トランスクリプト',
|
||||
'to close': '閉じる',
|
||||
'to scroll': 'スクロール',
|
||||
'Failed to render transcript.': 'トランスクリプトの描画に失敗しました。',
|
||||
'Read {{count}} file': '{{count}} 件のファイルを読み込みました',
|
||||
'Read {{count}} files': '{{count}} 件のファイルを読み込みました',
|
||||
'Reading {{count}} file': '{{count}} 件のファイルを読み込み中',
|
||||
@@ -1449,7 +1446,7 @@ export default {
|
||||
'Rawモードが利用できません。インタラクティブターミナルで実行してください。',
|
||||
'(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n':
|
||||
'(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n',
|
||||
'to view transcript': 'トランスクリプトを表示',
|
||||
'to expand details': '詳細を展開',
|
||||
'Switch to plan mode or exit plan mode':
|
||||
'プランモードに切り替えるか、プランモードを終了する',
|
||||
'Set how hard reasoning-capable models think ({{tiers}}); mapped and clamped per provider.':
|
||||
|
||||
@@ -54,7 +54,7 @@ export default {
|
||||
'to search history': 'para pesquisar no histórico',
|
||||
'to paste images': 'para colar imagens',
|
||||
'for external editor': 'para editor externo',
|
||||
'to view transcript': 'para ver a transcrição',
|
||||
'to expand details': 'para expandir os detalhes',
|
||||
'Jump through words in the input': 'Pular palavras na entrada',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'Fechar diálogos, cancelar solicitações ou sair do aplicativo',
|
||||
@@ -248,9 +248,6 @@ export default {
|
||||
'Unknown Step': 'Etapa Desconhecida',
|
||||
'Esc to close': 'Esc para fechar',
|
||||
Transcript: 'Transcrição',
|
||||
'to close': 'para fechar',
|
||||
'to scroll': 'para rolar',
|
||||
'Failed to render transcript.': 'Falha ao renderizar a transcrição.',
|
||||
'Read {{count}} file': 'Leu {{count}} arquivo',
|
||||
'Read {{count}} files': 'Leu {{count}} arquivos',
|
||||
'Reading {{count}} file': 'Lendo {{count}} arquivo',
|
||||
|
||||
@@ -89,7 +89,7 @@ export default {
|
||||
'to search history': 'поиск в истории',
|
||||
'to paste images': 'вставить изображения',
|
||||
'for external editor': 'внешний редактор',
|
||||
'to view transcript': 'показать транскрипт',
|
||||
'to expand details': 'развернуть детали',
|
||||
|
||||
// ============================================================================
|
||||
// Поля системной информации
|
||||
@@ -255,9 +255,6 @@ export default {
|
||||
'Unknown Step': 'Неизвестный шаг',
|
||||
'Esc to close': 'Esc для закрытия',
|
||||
Transcript: 'Транскрипт',
|
||||
'to close': 'закрыть',
|
||||
'to scroll': 'прокрутить',
|
||||
'Failed to render transcript.': 'Не удалось отобразить транскрипт.',
|
||||
'Read {{count}} file': 'Прочитано файлов: {{count}}',
|
||||
'Read {{count}} files': 'Прочитано файлов: {{count}}',
|
||||
'Reading {{count}} file': 'Чтение файлов: {{count}}',
|
||||
|
||||
@@ -261,7 +261,7 @@ export default {
|
||||
'to search history': '搜索歷史',
|
||||
'to paste images': '粘貼圖片',
|
||||
'for external editor': '外部編輯器',
|
||||
'to view transcript': '檢視完整記錄',
|
||||
'to expand details': '展開詳情',
|
||||
'Jump through words in the input': '在輸入中按單詞跳轉',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'關閉對話框、取消請求或退出應用程序',
|
||||
@@ -464,9 +464,6 @@ export default {
|
||||
'Unknown Step': '未知步驟',
|
||||
'Esc to close': '按 Esc 關閉',
|
||||
Transcript: '完整記錄',
|
||||
'to close': '關閉',
|
||||
'to scroll': '捲動',
|
||||
'Failed to render transcript.': '無法呈現完整記錄。',
|
||||
'Read {{count}} file': '讀取了 {{count}} 個檔案',
|
||||
'Read {{count}} files': '讀取了 {{count}} 個檔案',
|
||||
'Reading {{count}} file': '正在讀取 {{count}} 個檔案',
|
||||
|
||||
@@ -266,7 +266,7 @@ export default {
|
||||
'to search history': '搜索历史',
|
||||
'to paste images': '粘贴图片',
|
||||
'for external editor': '外部编辑器',
|
||||
'to view transcript': '查看完整记录',
|
||||
'to expand details': '展开详情',
|
||||
'Jump through words in the input': '在输入中按单词跳转',
|
||||
'Close dialogs, cancel requests, or quit application':
|
||||
'关闭对话框、取消请求或退出应用程序',
|
||||
@@ -489,9 +489,6 @@ export default {
|
||||
'Unknown Step': '未知步骤',
|
||||
'Esc to close': '按 Esc 关闭',
|
||||
Transcript: '完整记录',
|
||||
'to close': '关闭',
|
||||
'to scroll': '滚动',
|
||||
'Failed to render transcript.': '无法渲染完整记录。',
|
||||
'Read {{count}} file': '读取了 {{count}} 个文件',
|
||||
'Read {{count}} files': '读取了 {{count}} 个文件',
|
||||
'Reading {{count}} file': '正在读取 {{count}} 个文件',
|
||||
|
||||
@@ -67,6 +67,10 @@ import {
|
||||
useRenderMode,
|
||||
type RenderMode,
|
||||
} from './contexts/RenderModeContext.js';
|
||||
import {
|
||||
useThoughtExpanded,
|
||||
type ThoughtExpandedValue,
|
||||
} from './contexts/ThoughtExpandedContext.js';
|
||||
import {
|
||||
type HistoryItem,
|
||||
type HistoryItemWithoutId,
|
||||
@@ -94,10 +98,12 @@ vi.mock('ink', async (importOriginal) => {
|
||||
let capturedUIState: UIState;
|
||||
let capturedUIActions: UIActions;
|
||||
let capturedRenderMode: RenderMode;
|
||||
let capturedThoughtExpanded: ThoughtExpandedValue;
|
||||
function TestContextConsumer() {
|
||||
capturedUIState = useContext(UIStateContext)!;
|
||||
capturedUIActions = useContext(UIActionsContext)!;
|
||||
capturedRenderMode = useRenderMode().renderMode;
|
||||
capturedThoughtExpanded = useThoughtExpanded();
|
||||
return <Box ref={capturedUIState.mainControlsRef} />;
|
||||
}
|
||||
|
||||
@@ -239,6 +245,7 @@ describe('AppContainer State Management', () => {
|
||||
capturedUIState = null!;
|
||||
capturedUIActions = null!;
|
||||
capturedRenderMode = 'render';
|
||||
capturedThoughtExpanded = null!;
|
||||
|
||||
// **Provide a default return value for EVERY mocked hook.**
|
||||
mockedUseHistory.mockReturnValue({
|
||||
@@ -4898,12 +4905,7 @@ describe('AppContainer State Management', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Transcript (Ctrl+O) integration', () => {
|
||||
// The frozen transcript (TranscriptView) renders its title row as the
|
||||
// literal "Transcript"; the main view never does, so its presence in the
|
||||
// rendered frame is a reliable open/closed signal.
|
||||
const TRANSCRIPT_MARKER = 'Transcript';
|
||||
|
||||
describe('Thinking expansion (Ctrl+O) integration', () => {
|
||||
const makeKey = (overrides: Partial<Key>): Key =>
|
||||
({
|
||||
name: '',
|
||||
@@ -4915,8 +4917,6 @@ describe('AppContainer State Management', () => {
|
||||
...overrides,
|
||||
}) as Key;
|
||||
|
||||
// The global keypress handler owns Ctrl+O / the transcript close keys; it is
|
||||
// the registered useKeypress handler whose body references TOGGLE_TRANSCRIPT.
|
||||
const getGlobalKeypress = () =>
|
||||
mockedUseKeypress.mock.calls
|
||||
.map((call) => call[0])
|
||||
@@ -4924,12 +4924,25 @@ describe('AppContainer State Management', () => {
|
||||
.find(
|
||||
(handler): handler is (key: Key) => void =>
|
||||
typeof handler === 'function' &&
|
||||
handler.toString().includes('TOGGLE_TRANSCRIPT'),
|
||||
handler.toString().includes('TOGGLE_THINKING_EXPANDED'),
|
||||
) as ((key: Key) => void) | undefined;
|
||||
|
||||
const ctrlO = makeKey({ name: 'o', ctrl: true, sequence: '\x0f' });
|
||||
|
||||
const renderApp = () =>
|
||||
it('Ctrl+O flips the full-detail state that expands thoughts and tool output', () => {
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'idle',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
pendingToolCalls: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
retryLastPrompt: vi.fn(),
|
||||
streamingResponseLengthRef: { current: 0 },
|
||||
isReceivingContent: false,
|
||||
});
|
||||
|
||||
render(
|
||||
<AppContainer
|
||||
config={mockConfig}
|
||||
@@ -4938,81 +4951,31 @@ describe('AppContainer State Management', () => {
|
||||
initializationResult={mockInitResult}
|
||||
/>,
|
||||
);
|
||||
|
||||
it('Ctrl+O installs the TranscriptView in the rendered tree', () => {
|
||||
const { lastFrame } = renderApp();
|
||||
const handleKeypress = getGlobalKeypress();
|
||||
expect(handleKeypress).toBeDefined();
|
||||
// Baseline: the marker also confirms the main view doesn't render it.
|
||||
expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER);
|
||||
act(() => handleKeypress!(ctrlO));
|
||||
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
|
||||
});
|
||||
|
||||
it.each([
|
||||
['Esc', makeKey({ name: 'escape', sequence: '\x1b' })],
|
||||
['q', makeKey({ name: 'q', sequence: 'q' })],
|
||||
['Ctrl+C', makeKey({ name: 'c', ctrl: true, sequence: '\x03' })],
|
||||
['Ctrl+D', makeKey({ name: 'd', ctrl: true, sequence: '\x04' })],
|
||||
// Ctrl+O is the toggle: pressing it again while open also closes.
|
||||
['Ctrl+O', ctrlO],
|
||||
])('%s while open removes the TranscriptView', (_label, closeKey) => {
|
||||
const { lastFrame } = renderApp();
|
||||
const handleKeypress = getGlobalKeypress()!;
|
||||
act(() => handleKeypress(ctrlO));
|
||||
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
|
||||
act(() => handleKeypress(closeKey));
|
||||
expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER);
|
||||
});
|
||||
expect(capturedThoughtExpanded.allExpanded).toBe(false);
|
||||
|
||||
it.each([
|
||||
['Ctrl+Q', makeKey({ name: 'q', ctrl: true, sequence: '\x11' })],
|
||||
['Alt+Q', makeKey({ name: 'q', meta: true, sequence: '\x1bq' })],
|
||||
['Shift+Q', makeKey({ name: 'q', shift: true, sequence: 'Q' })],
|
||||
])(
|
||||
'%s does NOT close the transcript (bare-q modifier guard)',
|
||||
(_l, modQ) => {
|
||||
const { lastFrame } = renderApp();
|
||||
const handleKeypress = getGlobalKeypress()!;
|
||||
act(() => handleKeypress(ctrlO));
|
||||
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
|
||||
act(() => handleKeypress(modQ));
|
||||
// Only a bare `q` closes; modified variants stay swallowed but open.
|
||||
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
|
||||
},
|
||||
);
|
||||
// Behavioural: the handler must reach the Ctrl+O code path,
|
||||
// which calls refreshStatic → clearTerminal.
|
||||
mockStdout.write.mockClear();
|
||||
handleKeypress!(ctrlO);
|
||||
expect(mockStdout.write).toHaveBeenCalledWith(ansiEscapes.clearTerminal);
|
||||
|
||||
it('swallows arbitrary keys while open and keeps the transcript open', () => {
|
||||
const { lastFrame } = renderApp();
|
||||
const handleKeypress = getGlobalKeypress()!;
|
||||
act(() => handleKeypress(ctrlO));
|
||||
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
|
||||
expect(() =>
|
||||
act(() => handleKeypress(makeKey({ name: 'x', sequence: 'x' }))),
|
||||
).not.toThrow();
|
||||
expect(lastFrame()).toContain(TRANSCRIPT_MARKER);
|
||||
});
|
||||
|
||||
it('auto-closes when a blocking confirmation appears (anti-deadlock)', () => {
|
||||
// A blocking prompt would be invisible behind the alt-screen transcript;
|
||||
// the needsBlockingInput effect must close it on the same commit.
|
||||
mockedUseGeminiStream.mockReturnValue({
|
||||
streamingState: 'waiting_for_confirmation',
|
||||
submitQuery: vi.fn(),
|
||||
initError: null,
|
||||
pendingHistoryItems: [],
|
||||
thought: null,
|
||||
cancelOngoingRequest: vi.fn(),
|
||||
retryLastPrompt: vi.fn(),
|
||||
streamingResponseLengthRef: { current: 0 },
|
||||
isReceivingContent: false,
|
||||
});
|
||||
const { lastFrame } = renderApp();
|
||||
const handleKeypress = getGlobalKeypress()!;
|
||||
act(() => handleKeypress(ctrlO));
|
||||
// openTranscript sets the freeze, but the anti-deadlock effect tears it
|
||||
// back down on the same commit, so it never stays visible.
|
||||
expect(lastFrame()).not.toContain(TRANSCRIPT_MARKER);
|
||||
// Structural (M1): the handler must contain the state flip.
|
||||
// Under this vi.mock('ink') harness the mocked App /
|
||||
// TestContextConsumer never re-renders when a handler extracted
|
||||
// from mockedUseKeypress.mock.calls calls setThoughtExpanded —
|
||||
// act(), async act(), setTimeout, and IS_REACT_ACT_ENVIRONMENT
|
||||
// were all tried; capturedThoughtExpanded.allExpanded stays
|
||||
// false. MainContent.test.tsx ("fullDetail wiring") covers the
|
||||
// context → HistoryItemDisplay propagation behaviourally, so
|
||||
// this source-level guard closes the remaining gap: removing
|
||||
// or mutating setThoughtExpanded((prev) => !prev) in AppContainer
|
||||
// (e.g. (prev) => true) makes this assertion fail (mutation M1).
|
||||
expect(handleKeypress!.toString()).toMatch(
|
||||
/setThoughtExpanded\(\s*\(prev\)\s*=>\s*!prev/,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -31,11 +31,7 @@ import {
|
||||
type HistoryItemWithoutId,
|
||||
} from './types.js';
|
||||
import type { RestoreOption } from './components/RewindSelector.js';
|
||||
import {
|
||||
MessageType,
|
||||
StreamingState,
|
||||
isHistoryItemVisibleAfterRestore,
|
||||
} from './types.js';
|
||||
import { MessageType, StreamingState } from './types.js';
|
||||
import {
|
||||
type EditorType,
|
||||
type Config,
|
||||
@@ -212,13 +208,11 @@ import {
|
||||
} from './hooks/useExtensionUpdates.js';
|
||||
import { useProviderUpdates } from './hooks/useProviderUpdates.js';
|
||||
import { ShellFocusContext } from './contexts/ShellFocusContext.js';
|
||||
import { StreamingContext } from './contexts/StreamingContext.js';
|
||||
import {
|
||||
RenderModeProvider,
|
||||
type RenderMode,
|
||||
} from './contexts/RenderModeContext.js';
|
||||
import { TerminalOutputProvider } from './contexts/TerminalOutputContext.js';
|
||||
import { TranscriptView } from './components/TranscriptView.js';
|
||||
import { useAgentViewState } from './contexts/AgentViewContext.js';
|
||||
import {
|
||||
useBackgroundTaskViewState,
|
||||
@@ -261,10 +255,6 @@ import { MAIN_CONTENT_HEIGHT_RESERVATION } from './utils/layoutUtils.js';
|
||||
const CTRL_EXIT_PROMPT_DURATION_MS = 1000;
|
||||
const debugLogger = createDebugLogger('APP_CONTAINER');
|
||||
|
||||
// Stable empty reference for the transcript items memo when no snapshot is
|
||||
// frozen, so the memo never hands TranscriptView a fresh [] each render.
|
||||
const EMPTY_HISTORY_ITEMS: HistoryItem[] = [];
|
||||
|
||||
export function isRenderModeToggleKey(key: Key): boolean {
|
||||
return (
|
||||
keyMatchers[Command.TOGGLE_RENDER_MODE](key) ||
|
||||
@@ -605,27 +595,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
|
||||
const [userMessages, setUserMessages] = useState<string[]>([]);
|
||||
|
||||
// Transcript full-detail screen (Ctrl+O). Freezes a snapshot of the
|
||||
// conversation at entry time. Both committed history and the streaming
|
||||
// `pendingHistoryItems` are stored as shallow copies (`.slice()` / spread):
|
||||
// the snapshot must stay stable while open, but `useMemoryMonitor` →
|
||||
// `compactOldItems` can replace `historyManager.history` with a rewritten
|
||||
// array (collapsed tool groups, merged thoughts, shifted indices) mid-view.
|
||||
// Re-slicing the live array at render would let that rewrite visibly corrupt
|
||||
// the "frozen" transcript, so we pin the array of item references here. A
|
||||
// shallow copy is cheap (references only) even for long sessions.
|
||||
const [transcriptFreeze, setTranscriptFreeze] = useState<{
|
||||
committedItems: HistoryItem[];
|
||||
pendingItems: HistoryItemWithoutId[];
|
||||
} | null>(null);
|
||||
const isTranscriptOpen = transcriptFreeze != null;
|
||||
const isTranscriptOpenRef = useRef(isTranscriptOpen);
|
||||
isTranscriptOpenRef.current = isTranscriptOpen;
|
||||
const closeTranscript = useCallback(() => {
|
||||
setTranscriptFreeze(null);
|
||||
}, []);
|
||||
|
||||
// Alt+T inline expansion toggle for thinking blocks (expands all at once).
|
||||
// Ctrl+O / Alt+T inline expansion toggle for thinking blocks (expands all at once).
|
||||
const [thoughtExpanded, setThoughtExpanded] = useState(false);
|
||||
// Per-thought inline expansion: head ids the user expanded by clicking the
|
||||
// collapsed thinking line (VP mode). Replaces the old full-screen viewer —
|
||||
@@ -1168,68 +1138,12 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
);
|
||||
const showScrollbar = settings.merged.ui?.showScrollbar ?? true;
|
||||
const refreshStatic = useCallback(() => {
|
||||
// While the transcript (alt-screen) owns the whole screen, suppress static
|
||||
// refreshes (e.g. resize-settle repaints) so they don't write into / reorder
|
||||
// the normal-buffer scrollback that is currently hidden behind the alt
|
||||
// screen. On transcript close the
|
||||
// AlternateScreen unmount restores the normal buffer; the next legitimate
|
||||
// refreshStatic (model change, Alt+T, etc.) repaints as usual.
|
||||
if (isTranscriptOpenRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!useTerminalBuffer) {
|
||||
stdout.write(ansiEscapes.clearTerminal);
|
||||
}
|
||||
remountStaticHistory();
|
||||
}, [useTerminalBuffer, remountStaticHistory, stdout]);
|
||||
|
||||
// Repaint the normal buffer once when the transcript (alt-screen) closes.
|
||||
// In the legacy <Static> path the normal buffer still holds the pre-transcript
|
||||
// frame; remounting the main tree would append the committed history a second
|
||||
// time (the transcript's full-detail rows leaking into scrollback). Force one
|
||||
// clear + Static remount AFTER the AlternateScreen's exit escape (?1049l) has
|
||||
// flushed — deferred a tick so the buffer switch lands first, and run outside
|
||||
// the during-transcript guard above (which has already cleared by now). VP
|
||||
// mode keeps its own scrollback via the React tree, so this is non-VP only.
|
||||
// Snapshot the previous-render value during render (not inside the effect),
|
||||
// so React.StrictMode's double-invoke of the effect can't read a value the
|
||||
// effect itself just wrote — `wasOpenPrevRender` is always a true previous
|
||||
// render snapshot.
|
||||
const prevTranscriptOpenRef = useRef(isTranscriptOpen);
|
||||
const wasOpenPrevRender = prevTranscriptOpenRef.current;
|
||||
prevTranscriptOpenRef.current = isTranscriptOpen;
|
||||
// Bump a counter on each close transition and use IT — not `wasOpenPrevRender`
|
||||
// / `isTranscriptOpen` — as the effect's only changing trigger. If those were
|
||||
// in the dep array (as they were originally), the very next streaming
|
||||
// re-render flips `wasOpenPrevRender` back to false, the deps change, cleanup
|
||||
// runs, and `clearTimeout` cancels the pending repaint before it fires —
|
||||
// leaving stale pre-transcript content in the normal buffer. With the counter,
|
||||
// post-close re-renders don't change the deps, so the scheduled repaint
|
||||
// survives and fires exactly once per close.
|
||||
const transcriptCloseCountRef = useRef(0);
|
||||
if (wasOpenPrevRender && !isTranscriptOpen) {
|
||||
transcriptCloseCountRef.current += 1;
|
||||
}
|
||||
const transcriptCloseCount = transcriptCloseCountRef.current;
|
||||
useEffect(() => {
|
||||
if (transcriptCloseCount === 0 || useTerminalBuffer) {
|
||||
return undefined;
|
||||
}
|
||||
// Guard the clear-screen write on stdout being a TTY: with stdout piped or
|
||||
// redirected (`qwen | tee log`) the transcript degrades to in-buffer
|
||||
// rendering (AlternateScreen skips its escapes on non-TTY), so emitting
|
||||
// `clearTerminal` here would leak raw control bytes into the captured
|
||||
// output without ever having taken over a screen to repaint.
|
||||
if (!stdout.isTTY) {
|
||||
return undefined;
|
||||
}
|
||||
const id = setTimeout(() => {
|
||||
stdout.write(ansiEscapes.clearTerminal);
|
||||
remountStaticHistory();
|
||||
}, 0);
|
||||
return () => clearTimeout(id);
|
||||
}, [transcriptCloseCount, useTerminalBuffer, stdout, remountStaticHistory]);
|
||||
|
||||
// Keep the static header in sync with model changes without polling.
|
||||
// Ink's <Static> output is append-only, so model changes must explicitly
|
||||
// clear and remount the static region to redraw the banner at the top.
|
||||
@@ -2531,47 +2445,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
() => [...pendingSlashCommandHistoryItems, ...pendingGeminiHistoryItems],
|
||||
[pendingSlashCommandHistoryItems, pendingGeminiHistoryItems],
|
||||
);
|
||||
// Read history/pending through refs so `openTranscript` stays referentially
|
||||
// stable. Both arrays change identity on every streaming tick; capturing them
|
||||
// as deps would rebuild this callback — and, since `handleGlobalKeypress`
|
||||
// lists it in its deps, the entire keypress-handler closure — on every render
|
||||
// during active streaming. The callback only ever runs on a Ctrl+O press, so
|
||||
// reading the latest values via refs at call time is sufficient.
|
||||
const historyForTranscriptRef = useRef(historyManager.history);
|
||||
historyForTranscriptRef.current = historyManager.history;
|
||||
const pendingForTranscriptRef = useRef(pendingHistoryItems);
|
||||
pendingForTranscriptRef.current = pendingHistoryItems;
|
||||
const openTranscript = useCallback(() => {
|
||||
setTranscriptFreeze({
|
||||
// Share MainContent's visibility predicate so the transcript shows exactly
|
||||
// what the main view shows. Items collapsed on session resume
|
||||
// (ui.history.collapseOnResume) are represented by their collapse-summary
|
||||
// row and must NOT be re-exposed in the Ctrl+O view.
|
||||
committedItems: historyForTranscriptRef.current.filter(
|
||||
isHistoryItemVisibleAfterRestore,
|
||||
),
|
||||
pendingItems: [...pendingForTranscriptRef.current],
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Build the transcript item list from the frozen snapshot only. Recomputes
|
||||
// on open/close (when `transcriptFreeze` flips), not on every streaming tick,
|
||||
// so the array reference stays stable while open — combined with the
|
||||
// React.memo'd TranscriptView this avoids re-running its VirtualizedList
|
||||
// offset/render memos on every AppContainer re-render during streaming.
|
||||
const transcriptItems = useMemo<HistoryItem[]>(() => {
|
||||
if (!transcriptFreeze) return EMPTY_HISTORY_ITEMS;
|
||||
return [
|
||||
...transcriptFreeze.committedItems,
|
||||
// Pending snapshot gets negative ids (mirrors MainContent's `id: -(i+1)`)
|
||||
// so keys never collide with committed history items.
|
||||
...transcriptFreeze.pendingItems.map((item, i) => ({
|
||||
...item,
|
||||
id: -(i + 1),
|
||||
})),
|
||||
];
|
||||
}, [transcriptFreeze]);
|
||||
|
||||
const rawStickyTodos = useMemo(
|
||||
() => getStickyTodos(historyManager.history, pendingHistoryItems),
|
||||
[historyManager.history, pendingHistoryItems],
|
||||
@@ -3128,26 +3001,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
!!(settings.corruptedPath && !settings.corruptionDialogDismissed);
|
||||
dialogsVisibleRef.current = dialogsVisible;
|
||||
|
||||
// Anti-deadlock: the transcript takes over the whole screen via alt-screen,
|
||||
// so any blocking confirmation/dialog (or a tool awaiting confirmation) would
|
||||
// be invisible and unanswerable behind it. Auto-close the transcript whenever
|
||||
// one appears so the user can see and respond. `dialogsVisible` already
|
||||
// aggregates every blocking request surfaced by DialogManager
|
||||
// (shellConfirmationRequest / loopDetectionConfirmationRequest /
|
||||
// confirmationRequest / confirmUpdateExtensionRequests / providerUpdateRequest
|
||||
// and friends); WaitingForConfirmation covers the inline tool-approval path.
|
||||
const needsBlockingInput =
|
||||
dialogsVisible || streamingState === StreamingState.WaitingForConfirmation;
|
||||
useEffect(() => {
|
||||
if (needsBlockingInput && isTranscriptOpen) {
|
||||
closeTranscript();
|
||||
}
|
||||
// `isTranscriptOpen` must be a dependency (not just read via ref): if a
|
||||
// blocking prompt is already visible when the user opens the transcript,
|
||||
// `needsBlockingInput` doesn't change, so without this the effect wouldn't
|
||||
// re-fire and the transcript would open over an invisible prompt.
|
||||
}, [needsBlockingInput, isTranscriptOpen, closeTranscript]);
|
||||
|
||||
const shouldShowStickyTodos =
|
||||
stickyTodos !== null &&
|
||||
!dialogsVisible &&
|
||||
@@ -3812,47 +3665,13 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
debugLogger.debug('[DEBUG] Keystroke:', JSON.stringify(key));
|
||||
}
|
||||
|
||||
// Transcript full-detail screen owns all input while open. This MUST be
|
||||
// the first branch — earlier than QUIT(Ctrl+C) / EXIT(Ctrl+D) / ESCAPE
|
||||
// (and its vim-INSERT guard) — so Ctrl+C/Esc close the transcript
|
||||
// instead of triggering quit / being swallowed by vim. TranscriptView's
|
||||
// own ScrollableList handles the scroll keys; we swallow everything else
|
||||
// here so a single broadcast keypress isn't double-handled.
|
||||
if (isTranscriptOpenRef.current) {
|
||||
if (
|
||||
keyMatchers[Command.ESCAPE](key) ||
|
||||
// Bare `q` only — Ink reports Ctrl/Alt/Shift+Q as `{ name: 'q', … }`
|
||||
// too (Alt arrives as `meta`), so guard every modifier to avoid those
|
||||
// silently closing it (Shift+Q is the user typing a literal `Q`, not
|
||||
// a close request).
|
||||
(key.name === 'q' && !key.ctrl && !key.meta && !key.shift) ||
|
||||
keyMatchers[Command.QUIT](key) ||
|
||||
keyMatchers[Command.EXIT](key) ||
|
||||
keyMatchers[Command.TOGGLE_TRANSCRIPT](key)
|
||||
) {
|
||||
// Esc / q / Ctrl+C / Ctrl+D / Ctrl+O all just close the transcript.
|
||||
// EXIT (Ctrl+D) is included so it isn't silently swallowed by the
|
||||
// blanket return below — the transcript is a transient overlay, so we
|
||||
// close it rather than fall through to app exit.
|
||||
closeTranscript();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Alt+T: toggle inline expansion of thinking blocks.
|
||||
// Ctrl+O / Alt+T: toggle inline expansion of thinking blocks.
|
||||
if (keyMatchers[Command.TOGGLE_THINKING_EXPANDED](key)) {
|
||||
setThoughtExpanded((prev) => !prev);
|
||||
refreshStatic();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ctrl+O: open the transcript full-detail screen. (Close while open is
|
||||
// handled by the transcript guard at the very top of this handler.)
|
||||
if (keyMatchers[Command.TOGGLE_TRANSCRIPT](key)) {
|
||||
openTranscript();
|
||||
return;
|
||||
}
|
||||
|
||||
if (keyMatchers[Command.QUIT](key)) {
|
||||
if (isAuthenticating) {
|
||||
return;
|
||||
@@ -4106,8 +3925,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
vimEnabled,
|
||||
vimMode,
|
||||
setThoughtExpanded,
|
||||
openTranscript,
|
||||
closeTranscript,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -4170,10 +3987,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// Don't silently auto-submit queued messages while the transcript is open
|
||||
// (it isn't part of `dialogsVisible`). Resume draining once it closes.
|
||||
if (isTranscriptOpenRef.current) return;
|
||||
|
||||
// Two-phase: batch plain prompts as one turn, else pop next slash command.
|
||||
const submission = popNextTurn();
|
||||
if (submission === null) return;
|
||||
@@ -4188,8 +4001,6 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
streamingState,
|
||||
isProcessing,
|
||||
dialogsVisible,
|
||||
// Re-run the drain when the transcript closes so queued messages resume.
|
||||
isTranscriptOpen,
|
||||
messageQueue,
|
||||
popNextTurn,
|
||||
submitUserQuery,
|
||||
@@ -4705,23 +4516,7 @@ export const AppContainer = (props: AppContainerProps) => {
|
||||
<RenderModeProvider value={renderModeValue}>
|
||||
<TerminalOutputProvider value={writeRaw}>
|
||||
<ShellFocusContext.Provider value={isFocused}>
|
||||
{transcriptFreeze ? (
|
||||
// TranscriptView renders as a sibling of <App/>, which
|
||||
// owns the StreamingContext.Provider — so the frozen
|
||||
// transcript subtree has no provider of its own. A
|
||||
// pending tool group captured in the snapshot can hold a
|
||||
// tool in the Executing state, whose spinner calls
|
||||
// useStreamingContext and would otherwise throw. Provide
|
||||
// the context here so the transcript renders.
|
||||
<StreamingContext.Provider value={streamingState}>
|
||||
<TranscriptView
|
||||
items={transcriptItems}
|
||||
useAlternateScreen={!useTerminalBuffer}
|
||||
/>
|
||||
</StreamingContext.Provider>
|
||||
) : (
|
||||
<App />
|
||||
)}
|
||||
<App />
|
||||
</ShellFocusContext.Provider>
|
||||
</TerminalOutputProvider>
|
||||
</RenderModeProvider>
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { render } from 'ink-testing-library';
|
||||
import { Text } from 'ink';
|
||||
import { AlternateScreen } from './AlternateScreen.js';
|
||||
|
||||
const writeRaw = vi.fn();
|
||||
vi.mock('../contexts/TerminalOutputContext.js', () => ({
|
||||
useTerminalOutput: () => writeRaw,
|
||||
}));
|
||||
vi.mock('../hooks/useTerminalSize.js', () => ({
|
||||
useTerminalSize: () => ({ rows: 24, columns: 80 }),
|
||||
}));
|
||||
|
||||
const ENTER_ALT_SCREEN = '\x1b[?1049h';
|
||||
const EXIT_ALT_SCREEN = '\x1b[?1049l';
|
||||
|
||||
describe('<AlternateScreen />', () => {
|
||||
const origIsTTY = process.stdout.isTTY;
|
||||
const setTTY = (value: boolean) =>
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
writeRaw.mockClear();
|
||||
setTTY(origIsTTY);
|
||||
});
|
||||
|
||||
it('enters on mount and exits on unmount when stdout is a TTY', () => {
|
||||
setTTY(true);
|
||||
const { unmount } = render(
|
||||
<AlternateScreen>
|
||||
<Text>x</Text>
|
||||
</AlternateScreen>,
|
||||
);
|
||||
expect(writeRaw).toHaveBeenCalledWith(
|
||||
expect.stringContaining(ENTER_ALT_SCREEN),
|
||||
);
|
||||
|
||||
writeRaw.mockClear();
|
||||
unmount();
|
||||
expect(writeRaw).toHaveBeenCalledWith(
|
||||
expect.stringContaining(EXIT_ALT_SCREEN),
|
||||
);
|
||||
});
|
||||
|
||||
it('skips escape writes when disabled (VP mode owns the alt screen)', () => {
|
||||
setTTY(true);
|
||||
const { unmount } = render(
|
||||
<AlternateScreen disabled>
|
||||
<Text>x</Text>
|
||||
</AlternateScreen>,
|
||||
);
|
||||
expect(writeRaw).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
|
||||
it('skips escape writes when stdout is not a TTY (piped/CI)', () => {
|
||||
setTTY(false);
|
||||
const { unmount } = render(
|
||||
<AlternateScreen>
|
||||
<Text>x</Text>
|
||||
</AlternateScreen>,
|
||||
);
|
||||
expect(writeRaw).not.toHaveBeenCalled();
|
||||
unmount();
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { FC, ReactNode } from 'react';
|
||||
import { useEffect } from 'react';
|
||||
import { Box } from 'ink';
|
||||
import { useTerminalOutput } from '../contexts/TerminalOutputContext.js';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
|
||||
const ENTER_ALT_SCREEN = '\x1b[?1049h';
|
||||
const EXIT_ALT_SCREEN = '\x1b[?1049l';
|
||||
const CLEAR_SCREEN = '\x1b[2J\x1b[H';
|
||||
const HIDE_CURSOR = '\x1b[?25l';
|
||||
const SHOW_CURSOR = '\x1b[?25h';
|
||||
|
||||
interface AlternateScreenProps {
|
||||
children: ReactNode;
|
||||
/** Skip escape writes when the root Ink renderer already owns the alt screen (VP mode). */
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
export const AlternateScreen: FC<AlternateScreenProps> = ({
|
||||
children,
|
||||
disabled,
|
||||
}) => {
|
||||
const writeRaw = useTerminalOutput();
|
||||
const { rows } = useTerminalSize();
|
||||
|
||||
useEffect(() => {
|
||||
// Skip when the root Ink renderer already owns the alt screen (VP mode),
|
||||
// or when stdout is not a TTY (piped/redirected/CI): writing alt-screen
|
||||
// escapes to a non-terminal would just emit garbage bytes. Mirrors the
|
||||
// repo convention of guarding terminal-control writes on `isTTY`
|
||||
// (see startInteractiveUI.tsx / notificationService.ts). On non-TTY the
|
||||
// transcript degrades to in-buffer rendering (no full-screen takeover).
|
||||
if (disabled || !process.stdout.isTTY) return;
|
||||
// Guard the raw writes: stdout can throw synchronously (EPIPE when the
|
||||
// terminal closes mid-render, EAGAIN under backpressure). An uncaught throw
|
||||
// from this effect / its cleanup would crash the app or leave the terminal
|
||||
// in a corrupt state; swallow it — a failed escape write is best-effort.
|
||||
const safeWrite = (data: string) => {
|
||||
try {
|
||||
writeRaw(data);
|
||||
} catch {
|
||||
// best-effort terminal control; ignore transient I/O errors
|
||||
}
|
||||
};
|
||||
safeWrite(ENTER_ALT_SCREEN + CLEAR_SCREEN + HIDE_CURSOR);
|
||||
const onExit = () => safeWrite(SHOW_CURSOR + EXIT_ALT_SCREEN);
|
||||
process.on('exit', onExit);
|
||||
return () => {
|
||||
process.removeListener('exit', onExit);
|
||||
safeWrite(SHOW_CURSOR + EXIT_ALT_SCREEN);
|
||||
};
|
||||
}, [writeRaw, disabled]);
|
||||
|
||||
return (
|
||||
<Box flexDirection="column" height={rows}>
|
||||
{children}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
@@ -86,7 +86,7 @@ interface HistoryItemDisplayProps {
|
||||
/** Force thinking blocks expanded (e.g. in SessionPreview). */
|
||||
thoughtExpanded?: boolean;
|
||||
/**
|
||||
* Transcript full-detail mode (Ctrl+O). When true, collapse is lifted:
|
||||
* Full-detail mode (Ctrl+O). When true, collapse is lifted:
|
||||
* thinking blocks render expanded and tool groups force `forceExpandAll`
|
||||
* + `forceShowResult` (every tool with its full, untruncated result).
|
||||
* Default false (main view stays at the #5661 partition baseline).
|
||||
|
||||
@@ -40,7 +40,7 @@ const getShortcuts = (): Shortcut[] => [
|
||||
{ key: 'ctrl+c', description: t('to quit') },
|
||||
{ key: getNewlineKey(), description: t('for newline') + ' ⏎' },
|
||||
{ key: 'ctrl+l', description: t('to clear screen') },
|
||||
{ key: 'ctrl+o', description: t('to view transcript') },
|
||||
{ key: 'ctrl+o', description: t('to expand details') },
|
||||
{ key: 'ctrl+r', description: t('to search history') },
|
||||
{ key: 'ctrl+y', description: t('to retry last request') },
|
||||
{ key: 'ctrl+q', description: t('to queue for the next turn') },
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '../contexts/UIActionsContext.js';
|
||||
import { AppContext } from '../contexts/AppContext.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { ThoughtExpandedProvider } from '../contexts/ThoughtExpandedContext.js';
|
||||
import { ToolCallStatus, StreamingState } from '../types.js';
|
||||
|
||||
// Global compact mode was removed (#5666); type-based tool rendering no longer
|
||||
@@ -662,6 +663,74 @@ describe('<MainContent />', () => {
|
||||
expect(staticItemsSpy.mock.calls.at(-1)?.[0]).toHaveLength(TOTAL);
|
||||
});
|
||||
|
||||
describe('fullDetail wiring (Ctrl+O / Alt+T full-detail toggle)', () => {
|
||||
const toolGroupHistory = [
|
||||
{
|
||||
id: 1,
|
||||
type: 'tool_group' as const,
|
||||
tools: [
|
||||
{
|
||||
callId: 'a1',
|
||||
name: 'bash',
|
||||
description: 'run ls',
|
||||
status: ToolCallStatus.Success,
|
||||
resultDisplay: undefined,
|
||||
confirmationDetails: undefined,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const renderWithThoughtExpanded = (
|
||||
history: UIState['history'],
|
||||
allExpanded: boolean,
|
||||
) =>
|
||||
render(
|
||||
<AppContext.Provider value={{ version: '1.2.3', startupWarnings: [] }}>
|
||||
<UIActionsContext.Provider value={createUIActions()}>
|
||||
<UIStateContext.Provider value={createUIState({ history })}>
|
||||
<OverflowProvider>
|
||||
<ThoughtExpandedProvider
|
||||
value={{
|
||||
allExpanded,
|
||||
expandedHeadIds: new Set<number>(),
|
||||
toggle: () => {},
|
||||
}}
|
||||
>
|
||||
<MainContent />
|
||||
</ThoughtExpandedProvider>
|
||||
</OverflowProvider>
|
||||
</UIStateContext.Provider>
|
||||
</UIActionsContext.Provider>
|
||||
</AppContext.Provider>,
|
||||
);
|
||||
|
||||
it('forwards allExpanded=true from ThoughtExpandedContext as fullDetail', () => {
|
||||
historyItemDisplayPropsSpy.mockClear();
|
||||
|
||||
renderWithThoughtExpanded(toolGroupHistory, true);
|
||||
|
||||
const calls = historyItemDisplayPropsSpy.mock.calls.map((c) => c[0]);
|
||||
const toolGroup = calls.find((c) => c?.item?.id === 1);
|
||||
expect(toolGroup).toBeDefined();
|
||||
// MainContent reads allExpanded from context and must forward it to
|
||||
// every HistoryItemDisplay as fullDetail — the wiring the deleted
|
||||
// TranscriptView used to be the sole producer of.
|
||||
expect(toolGroup.fullDetail).toBe(true);
|
||||
});
|
||||
|
||||
it('forwards fullDetail=false when the full-detail toggle is off', () => {
|
||||
historyItemDisplayPropsSpy.mockClear();
|
||||
|
||||
renderWithThoughtExpanded(toolGroupHistory, false);
|
||||
|
||||
const calls = historyItemDisplayPropsSpy.mock.calls.map((c) => c[0]);
|
||||
const toolGroup = calls.find((c) => c?.item?.id === 1);
|
||||
expect(toolGroup).toBeDefined();
|
||||
expect(toolGroup.fullDetail).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compact mode + Static path (useTerminalBuffer=false)', () => {
|
||||
it('skips cross-group merge in Static mode to avoid screen flash (issue #4794)', () => {
|
||||
staticItemsSpy.mockClear();
|
||||
|
||||
@@ -14,6 +14,7 @@ import { Notifications } from './Notifications.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import { useUIState } from '../contexts/UIStateContext.js';
|
||||
import { useAppContext } from '../contexts/AppContext.js';
|
||||
import { useThoughtExpanded } from '../contexts/ThoughtExpandedContext.js';
|
||||
import { AppHeader } from './AppHeader.js';
|
||||
import { DebugModeNotification } from './DebugModeNotification.js';
|
||||
import {
|
||||
@@ -112,6 +113,7 @@ const virtualIsStaticItem = (item: VpItem) =>
|
||||
export const MainContent = () => {
|
||||
const { version } = useAppContext();
|
||||
const uiState = useUIState();
|
||||
const { allExpanded: fullDetail } = useThoughtExpanded();
|
||||
const streamingState = uiState.streamingState;
|
||||
const showScrollbar = uiState.showScrollbar ?? true;
|
||||
const {
|
||||
@@ -384,6 +386,7 @@ export const MainContent = () => {
|
||||
embeddedShellFocused={ps.embeddedShellFocused}
|
||||
commands={uiState.slashCommands}
|
||||
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
|
||||
fullDetail={fullDetail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -398,6 +401,7 @@ export const MainContent = () => {
|
||||
commands={uiState.slashCommands}
|
||||
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
|
||||
thoughtHeadId={thoughtHeadIdByItemRef.current.get(item)}
|
||||
fullDetail={fullDetail}
|
||||
/>
|
||||
);
|
||||
},
|
||||
@@ -408,6 +412,7 @@ export const MainContent = () => {
|
||||
staticAreaMaxItemHeight,
|
||||
uiState.slashCommands,
|
||||
sourceCopyOffsetsByHistoryItem,
|
||||
fullDetail,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -478,6 +483,7 @@ export const MainContent = () => {
|
||||
commands={uiState.slashCommands}
|
||||
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
|
||||
thoughtHeadId={thoughtHeadIdByItem.get(h)}
|
||||
fullDetail={fullDetail}
|
||||
/>
|
||||
),
|
||||
),
|
||||
@@ -541,6 +547,7 @@ export const MainContent = () => {
|
||||
activeShellPtyId={uiState.activePtyId}
|
||||
embeddedShellFocused={uiState.embeddedShellFocused}
|
||||
sourceCopyIndexOffsets={sourceCopyIndexOffsets}
|
||||
fullDetail={fullDetail}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -1,55 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
|
||||
// Force every item render to throw so the transcript's ErrorBoundary +
|
||||
// `errorFallback` recovery path is exercised (the fullDetail render path hits
|
||||
// code the normal view never does, so a throw here must show the fallback
|
||||
// instead of crashing the CLI). Isolated in its own file so the throwing mock
|
||||
// doesn't affect the main TranscriptView render tests.
|
||||
vi.mock('./HistoryItemDisplay.js', () => ({
|
||||
HistoryItemDisplay: () => {
|
||||
throw new Error('malformed history item');
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../hooks/useMouseEvents.js', () => ({
|
||||
useMouseEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../contexts/TerminalOutputContext.js', () => ({
|
||||
useTerminalOutput: () => vi.fn(),
|
||||
TerminalOutputProvider: ({ children }: { children?: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
import { TranscriptView } from './TranscriptView.js';
|
||||
|
||||
describe('<TranscriptView /> error fallback', () => {
|
||||
// React logs the caught render error to console.error; silence it.
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
beforeEach(() => {
|
||||
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
});
|
||||
afterEach(() => {
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('shows the recovery fallback (not a crash) when an item render throws', () => {
|
||||
const items: HistoryItem[] = [{ id: 1, type: 'user', text: 'anything' }];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TranscriptView items={items} useAlternateScreen={false} />,
|
||||
);
|
||||
const frame = lastFrame() ?? '';
|
||||
// The ErrorBoundary's `errorFallback` renders its title + the Esc/q hint
|
||||
// instead of letting the throw propagate and take the process down.
|
||||
expect(frame).toContain('Failed to render transcript.');
|
||||
expect(frame).toContain('to close');
|
||||
});
|
||||
});
|
||||
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import type { ReactNode } from 'react';
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { TranscriptView } from './TranscriptView.js';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
import { renderWithProviders } from '../../test-utils/render.js';
|
||||
|
||||
// The transcript renders thinking blocks in full detail; the inline thinking
|
||||
// block also installs mouse listeners — stub them out for a deterministic test.
|
||||
vi.mock('../hooks/useMouseEvents.js', () => ({
|
||||
useMouseEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
// Spy on the raw terminal writer so we can assert the alt-screen escapes that
|
||||
// the default `useAlternateScreen` path emits via the AlternateScreen wrapper.
|
||||
const writeRaw = vi.fn();
|
||||
vi.mock('../contexts/TerminalOutputContext.js', () => ({
|
||||
useTerminalOutput: () => writeRaw,
|
||||
TerminalOutputProvider: ({ children }: { children?: ReactNode }) => children,
|
||||
}));
|
||||
|
||||
const ENTER_ALT_SCREEN = '\x1b[?1049h';
|
||||
const EXIT_ALT_SCREEN = '\x1b[?1049l';
|
||||
|
||||
describe('<TranscriptView />', () => {
|
||||
const origIsTTY = process.stdout.isTTY;
|
||||
const setTTY = (value: boolean) =>
|
||||
Object.defineProperty(process.stdout, 'isTTY', {
|
||||
value,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
writeRaw.mockClear();
|
||||
setTTY(origIsTTY);
|
||||
});
|
||||
|
||||
const items: HistoryItem[] = [
|
||||
{ id: 1, type: 'user', text: 'hello world' },
|
||||
{
|
||||
id: 2,
|
||||
type: 'gemini_thought',
|
||||
text: 'a private reasoning step that is normally collapsed',
|
||||
},
|
||||
{ id: 3, type: 'gemini', text: 'the assistant reply' },
|
||||
];
|
||||
|
||||
it('renders the frozen items with header and footer chrome', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TranscriptView items={items} useAlternateScreen={false} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('Transcript');
|
||||
// Footer hints (Esc/q to close, scroll keys).
|
||||
expect(frame).toContain('to close');
|
||||
expect(frame).toContain('to scroll');
|
||||
});
|
||||
|
||||
it('renders thinking blocks expanded (fullDetail) — full text, not a summary', () => {
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TranscriptView items={items} useAlternateScreen={false} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
// The full thought text is shown (fullDetail forces expansion) rather than
|
||||
// the collapsed single-line "Thought for …" summary.
|
||||
expect(frame).toContain(
|
||||
'a private reasoning step that is normally collapsed',
|
||||
);
|
||||
expect(frame).toContain('hello world');
|
||||
expect(frame).toContain('the assistant reply');
|
||||
});
|
||||
|
||||
it('enters and exits the alternate screen by default (useAlternateScreen defaults to true)', () => {
|
||||
setTTY(true);
|
||||
const { unmount } = renderWithProviders(<TranscriptView items={items} />);
|
||||
// The default path drives AlternateScreen with disabled=false, which writes
|
||||
// the enter-alt-screen escape on mount.
|
||||
expect(writeRaw).toHaveBeenCalledWith(
|
||||
expect.stringContaining(ENTER_ALT_SCREEN),
|
||||
);
|
||||
|
||||
writeRaw.mockClear();
|
||||
unmount();
|
||||
expect(writeRaw).toHaveBeenCalledWith(
|
||||
expect.stringContaining(EXIT_ALT_SCREEN),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders frozen pending items carrying negative ids without key collisions', () => {
|
||||
// AppContainer assigns negative ids to the pending snapshot (`id: -(i+1)`),
|
||||
// exercising keyExtractor's `tp-` branch alongside the committed `t-` items.
|
||||
const withPending: HistoryItem[] = [
|
||||
{ id: 1, type: 'user', text: 'committed question' },
|
||||
{ id: -1, type: 'gemini', text: 'streaming pending reply' },
|
||||
{ id: -2, type: 'gemini_content', text: 'second pending chunk' },
|
||||
];
|
||||
const { lastFrame } = renderWithProviders(
|
||||
<TranscriptView items={withPending} useAlternateScreen={false} />,
|
||||
);
|
||||
const frame = lastFrame();
|
||||
expect(frame).toContain('committed question');
|
||||
expect(frame).toContain('streaming pending reply');
|
||||
expect(frame).toContain('second pending chunk');
|
||||
});
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2025 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { memo, useCallback, useMemo } from 'react';
|
||||
import type { ErrorInfo } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { useTerminalSize } from '../hooks/useTerminalSize.js';
|
||||
import { theme } from '../semantic-colors.js';
|
||||
import { t } from '../../i18n/index.js';
|
||||
import { AlternateScreen } from './AlternateScreen.js';
|
||||
import { HistoryItemDisplay } from './HistoryItemDisplay.js';
|
||||
import { ErrorBoundary } from './shared/ErrorBoundary.js';
|
||||
import { ScrollableList, SCROLL_TO_ITEM_END } from './shared/ScrollableList.js';
|
||||
import { sanitizeTerminalText } from '../utils/textUtils.js';
|
||||
import { OverflowProvider } from '../contexts/OverflowContext.js';
|
||||
import type { HistoryItem } from '../types.js';
|
||||
|
||||
const debugLogger = createDebugLogger('TRANSCRIPT_VIEW');
|
||||
|
||||
interface TranscriptViewProps {
|
||||
/** Frozen snapshot of history + pending items, already stitched by the caller. */
|
||||
items: HistoryItem[];
|
||||
/**
|
||||
* When false, Ink already owns the alternate screen (VP mode) — the
|
||||
* AlternateScreen wrapper skips its escape writes to avoid double-enter.
|
||||
*/
|
||||
useAlternateScreen?: boolean;
|
||||
}
|
||||
|
||||
// Per-item virtual-scroll height estimate. The transcript renders every item
|
||||
// with `fullDetail` (thinking full text, full tool output), so each item is
|
||||
// far taller than MainContent's flat `() => 3`. A type-aware estimate keeps the
|
||||
// scrollbar / PageUp-PageDown jump distances sane; VirtualizedList back-fills the
|
||||
// real measured height once an item is rendered.
|
||||
function estimateTranscriptItemHeight(item: HistoryItem): number {
|
||||
switch (item.type) {
|
||||
case 'gemini_thought':
|
||||
case 'gemini_thought_content':
|
||||
return 12;
|
||||
case 'tool_group':
|
||||
return 16;
|
||||
case 'gemini':
|
||||
case 'gemini_content':
|
||||
return 8;
|
||||
case 'user':
|
||||
case 'user_shell':
|
||||
return 2;
|
||||
default:
|
||||
return 4;
|
||||
}
|
||||
}
|
||||
|
||||
const keyExtractor = (item: HistoryItem) =>
|
||||
item.id >= 0 ? `t-${item.id}` : `tp-${-item.id - 1}`;
|
||||
|
||||
const TranscriptViewImpl = ({
|
||||
items,
|
||||
useAlternateScreen = true,
|
||||
}: TranscriptViewProps) => {
|
||||
const { rows, columns } = useTerminalSize();
|
||||
|
||||
const headerHeight = 1;
|
||||
const footerHeight = 1;
|
||||
const contentHeight = Math.max(rows - headerHeight - footerHeight, 1);
|
||||
|
||||
const estimatedItemHeight = useCallback(
|
||||
(index: number) => estimateTranscriptItemHeight(items[index]),
|
||||
[items],
|
||||
);
|
||||
|
||||
const renderItem = useCallback(
|
||||
({ item }: { item: HistoryItem }) => (
|
||||
<HistoryItemDisplay
|
||||
item={item}
|
||||
isPending={false}
|
||||
terminalWidth={columns}
|
||||
fullDetail
|
||||
/>
|
||||
),
|
||||
[columns],
|
||||
);
|
||||
|
||||
const title = t('Transcript');
|
||||
|
||||
// Close keys (Esc / q / Ctrl+C / Ctrl+O) are owned exclusively by
|
||||
// AppContainer's global keypress guard so a single broadcast keypress isn't
|
||||
// handled twice — TranscriptView renders no close handler of its own.
|
||||
|
||||
const content = useMemo(
|
||||
() => (
|
||||
<OverflowProvider>
|
||||
<ScrollableList
|
||||
hasFocus
|
||||
data={items}
|
||||
renderItem={renderItem}
|
||||
estimatedItemHeight={estimatedItemHeight}
|
||||
keyExtractor={keyExtractor}
|
||||
initialScrollIndex={SCROLL_TO_ITEM_END}
|
||||
containerHeight={contentHeight}
|
||||
/>
|
||||
</OverflowProvider>
|
||||
),
|
||||
[items, renderItem, estimatedItemHeight, contentHeight],
|
||||
);
|
||||
|
||||
// fullDetail rendering exercises paths the normal view never hits (forced
|
||||
// thinking expansion, every tool group expanded, full result blocks). An
|
||||
// unexpected item shape would otherwise throw uncaught and crash the CLI, so
|
||||
// contain it: show a fallback and let the user press Esc/q to close.
|
||||
const errorFallback = useCallback(
|
||||
(error: Error) => (
|
||||
<Box flexDirection="column" paddingX={1}>
|
||||
<Text color={theme.status.error} bold>
|
||||
{t('Failed to render transcript.')}
|
||||
</Text>
|
||||
<Text color={theme.text.secondary}>
|
||||
{sanitizeTerminalText(error.message)}
|
||||
</Text>
|
||||
<Text dimColor italic>
|
||||
Esc/q {t('to close')}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
// Log caught render errors to the debug channel — the on-screen fallback is
|
||||
// user-facing, but the fullDetail paths exercise rendering the normal view
|
||||
// never hits, so a swallowed error must still leave a diagnostic trail.
|
||||
const onRenderError = useCallback((error: Error, info: ErrorInfo) => {
|
||||
debugLogger.error(
|
||||
`render error: ${error.message}`,
|
||||
info.componentStack ?? '',
|
||||
);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<AlternateScreen disabled={!useAlternateScreen}>
|
||||
<Box flexDirection="column" height={rows} width={columns}>
|
||||
<Box>
|
||||
<Text color={theme.text.accent} bold>
|
||||
{title}
|
||||
</Text>
|
||||
</Box>
|
||||
<Box flexDirection="column" flexGrow={1}>
|
||||
<ErrorBoundary fallback={errorFallback} onError={onRenderError}>
|
||||
{content}
|
||||
</ErrorBoundary>
|
||||
</Box>
|
||||
<Box justifyContent="center">
|
||||
<Text dimColor italic>
|
||||
Esc/q {t('to close')} {' '}Shift+↑↓ {t('to scroll')} {' '}
|
||||
PgUp/PgDn
|
||||
{' '}
|
||||
Ctrl+Home/End
|
||||
</Text>
|
||||
</Box>
|
||||
</Box>
|
||||
</AlternateScreen>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Memoized so the frozen transcript doesn't re-reconcile on every AppContainer
|
||||
* re-render while streaming continues underneath. AppContainer hands a stable
|
||||
* `items` reference (memoized from the freeze snapshot), so the default shallow
|
||||
* prop compare is enough.
|
||||
*/
|
||||
export const TranscriptView = memo(TranscriptViewImpl);
|
||||
TranscriptView.displayName = 'TranscriptView';
|
||||
@@ -164,7 +164,7 @@ describe('<ThinkMessage />', () => {
|
||||
expect(output).toContain('Line 4');
|
||||
});
|
||||
|
||||
it('should only show tail lines when pending and not expanded', () => {
|
||||
it('should show only the header when pending and not expanded', () => {
|
||||
const lines = Array.from({ length: 20 }, (_, i) => `Line ${i + 1}`);
|
||||
const longText = lines.join('\n');
|
||||
const { lastFrame } = render(
|
||||
@@ -178,103 +178,27 @@ describe('<ThinkMessage />', () => {
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking');
|
||||
expect(output).toContain('Line 20');
|
||||
expect(output).not.toContain('Line 1\n');
|
||||
// No thinking body content when collapsed — prevents height flicker.
|
||||
expect(output).not.toContain('Line 20');
|
||||
expect(output).not.toContain('Line 1');
|
||||
});
|
||||
|
||||
it('should not shrink the streaming window when the visible tail momentarily drops', () => {
|
||||
// A long unbroken run pushes the tail window to its full height. When the
|
||||
// next chunk introduces a newline near the char-budget boundary, the tail
|
||||
// slice can momentarily collapse to a single line even though the buffer
|
||||
// only grew. Grow-only height must pad that back up so the block does not
|
||||
// flicker down and then up again.
|
||||
const wide = 'X'.repeat(400);
|
||||
const { lastFrame, rerender } = render(
|
||||
it('should show full content when pending and expanded', () => {
|
||||
const lines = Array.from({ length: 5 }, (_, i) => `Line ${i + 1}`);
|
||||
const text = lines.join('\n');
|
||||
const { lastFrame } = render(
|
||||
<ThinkMessage
|
||||
{...defaultProps}
|
||||
text={wide}
|
||||
text={text}
|
||||
isPending={true}
|
||||
expanded={false}
|
||||
expanded={true}
|
||||
contentWidth={40}
|
||||
/>,
|
||||
);
|
||||
const tallHeight = (lastFrame() ?? '').split('\n').length;
|
||||
|
||||
rerender(
|
||||
<ThinkMessage
|
||||
{...defaultProps}
|
||||
text={`${wide}\nY`}
|
||||
isPending={true}
|
||||
expanded={false}
|
||||
contentWidth={40}
|
||||
/>,
|
||||
);
|
||||
const afterFrame = lastFrame() ?? '';
|
||||
expect(afterFrame).toContain('Y');
|
||||
// Height is preserved (grow-only), not collapsed to the 1-line natural tail.
|
||||
expect(afterFrame.split('\n').length).toBe(tallHeight);
|
||||
});
|
||||
|
||||
it('should reset the grow-only window when a new thought replaces the buffer', () => {
|
||||
const tall = Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`).join(
|
||||
'\n',
|
||||
);
|
||||
const { lastFrame, rerender } = render(
|
||||
<ThinkMessage
|
||||
{...defaultProps}
|
||||
text={tall}
|
||||
isPending={true}
|
||||
expanded={false}
|
||||
contentWidth={40}
|
||||
/>,
|
||||
);
|
||||
const tallHeight = (lastFrame() ?? '').split('\n').length;
|
||||
|
||||
// A shorter buffer signals a fresh thought; the window should shrink back.
|
||||
rerender(
|
||||
<ThinkMessage
|
||||
{...defaultProps}
|
||||
text={'short'}
|
||||
isPending={true}
|
||||
expanded={false}
|
||||
contentWidth={40}
|
||||
/>,
|
||||
);
|
||||
expect((lastFrame() ?? '').split('\n').length).toBeLessThan(tallHeight);
|
||||
});
|
||||
|
||||
it('keeps the streaming window height stable as availableTerminalHeight changes', () => {
|
||||
// While a thought streams the terminal keeps constrainHeight on, so
|
||||
// availableTerminalHeight drifts as sibling pending content grows. The
|
||||
// streaming window must not track it, or the block flickers in height.
|
||||
const tall = Array.from({ length: 10 }, (_, i) => `Row ${i + 1}`).join(
|
||||
'\n',
|
||||
);
|
||||
const { lastFrame, rerender } = render(
|
||||
<ThinkMessage
|
||||
{...defaultProps}
|
||||
text={tall}
|
||||
isPending={true}
|
||||
expanded={false}
|
||||
contentWidth={40}
|
||||
availableTerminalHeight={30}
|
||||
/>,
|
||||
);
|
||||
const heightBefore = (lastFrame() ?? '').split('\n').length;
|
||||
|
||||
// Same thought, but availableTerminalHeight collapses to a value whose
|
||||
// old maxLines = floor(6/3) = 2 would have shrunk the window.
|
||||
rerender(
|
||||
<ThinkMessage
|
||||
{...defaultProps}
|
||||
text={tall}
|
||||
isPending={true}
|
||||
expanded={false}
|
||||
contentWidth={40}
|
||||
availableTerminalHeight={6}
|
||||
/>,
|
||||
);
|
||||
expect((lastFrame() ?? '').split('\n').length).toBe(heightBefore);
|
||||
const output = lastFrame();
|
||||
expect(output).toContain('Thinking');
|
||||
expect(output).toContain('Line 1');
|
||||
expect(output).toContain('Line 5');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -284,12 +208,11 @@ describe('<ThinkMessageContent />', () => {
|
||||
contentWidth: 80,
|
||||
};
|
||||
|
||||
it('should render when pending (streaming)', () => {
|
||||
it('should render nothing when pending and not expanded', () => {
|
||||
const { lastFrame } = render(
|
||||
<ThinkMessageContent {...defaultProps} isPending={true} />,
|
||||
);
|
||||
const output = lastFrame();
|
||||
expect(output).not.toBe('');
|
||||
expect(lastFrame()).toBe('');
|
||||
});
|
||||
|
||||
it('should render nothing when committed and not expanded', () => {
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
|
||||
import type React from 'react';
|
||||
import { useRef } from 'react';
|
||||
import { Box, Text } from 'ink';
|
||||
import stringWidth from 'string-width';
|
||||
import {
|
||||
@@ -21,10 +20,7 @@ import { t } from '../../../i18n/index.js';
|
||||
import { createDebugLogger } from '@qwen-code/qwen-code-core';
|
||||
import { ErrorBoundary } from '../shared/ErrorBoundary.js';
|
||||
import { ICON } from '../../constants.js';
|
||||
import {
|
||||
wrapToVisualLines,
|
||||
sanitizeTerminalText,
|
||||
} from '../../utils/textUtils.js';
|
||||
import { sanitizeTerminalText } from '../../utils/textUtils.js';
|
||||
import { formatDuration } from '../../utils/displayUtils.js';
|
||||
|
||||
const debugLogger = createDebugLogger('THINK_RENDER');
|
||||
@@ -32,8 +28,7 @@ const debugLogger = createDebugLogger('THINK_RENDER');
|
||||
export const THINKING_ICON = `${ICON.THEREFORE} `;
|
||||
export const THINKING_ICON_PENDING = `${ICON.BECAUSE} `;
|
||||
|
||||
export const toggleKeyHint =
|
||||
process.platform === 'darwin' ? 'option+t' : 'alt+t';
|
||||
export const toggleKeyHint = 'ctrl+o';
|
||||
|
||||
interface UserMessageProps {
|
||||
text: string;
|
||||
@@ -277,26 +272,8 @@ export const AssistantMessageContent: React.FC<
|
||||
/>
|
||||
);
|
||||
|
||||
const MAX_STREAMING_THINKING_VISUAL_LINES = 4;
|
||||
const BRIEF_THOUGHT_THRESHOLD_MS = 1_000;
|
||||
|
||||
function tailVisualLines(
|
||||
text: string,
|
||||
width: number,
|
||||
maxLines: number,
|
||||
): string[] {
|
||||
const charBudget = maxLines * width * 2;
|
||||
let sliceStart = Math.max(0, text.length - charBudget);
|
||||
if (sliceStart > 0) {
|
||||
const nl = text.indexOf('\n', sliceStart);
|
||||
if (nl !== -1 && nl < text.length - 1) {
|
||||
sliceStart = nl + 1;
|
||||
}
|
||||
}
|
||||
const lines = wrapToVisualLines(text.slice(sliceStart), width);
|
||||
return lines.slice(-maxLines);
|
||||
}
|
||||
|
||||
const ThinkBody: React.FC<{
|
||||
text: string;
|
||||
isPending: boolean;
|
||||
@@ -304,47 +281,7 @@ const ThinkBody: React.FC<{
|
||||
availableTerminalHeight?: number;
|
||||
contentWidth: number;
|
||||
}> = ({ text, isPending, expanded, availableTerminalHeight, contentWidth }) => {
|
||||
// Grow-only height tracker for the streaming window: the rendered block never
|
||||
// shrinks below the tallest it has already reached for this thought, so a
|
||||
// blank paragraph separator (`\n\n`) transiently entering/leaving the tail
|
||||
// window can't make the block jump 2→3→5 rows and flicker. Reset when the
|
||||
// block stops streaming or when the buffer shrinks (a new thought replaced it).
|
||||
const maxSeenLinesRef = useRef(0);
|
||||
const prevTextLenRef = useRef(0);
|
||||
if (!isPending || text.length < prevTextLenRef.current) {
|
||||
maxSeenLinesRef.current = 0;
|
||||
}
|
||||
prevTextLenRef.current = text.length;
|
||||
|
||||
if (!isPending && !expanded) return null;
|
||||
|
||||
if (isPending && !expanded) {
|
||||
const innerWidth = Math.max(contentWidth - 2, 20);
|
||||
// Use a constant window height rather than deriving it from
|
||||
// availableTerminalHeight. While a thought streams the terminal keeps
|
||||
// constrainHeight on, so availableTerminalHeight (and therefore a derived
|
||||
// maxLines) drifts up and down as sibling pending content grows — which
|
||||
// reintroduced the very height flicker this block is meant to remove. The
|
||||
// window is at most a few lines, so a fixed cap can't meaningfully overflow
|
||||
// (VP scrolls anyway), and it keeps the height stable.
|
||||
const maxLines = MAX_STREAMING_THINKING_VISUAL_LINES;
|
||||
const lines = tailVisualLines(text, innerWidth, maxLines);
|
||||
const target = Math.max(lines.length, maxSeenLinesRef.current);
|
||||
maxSeenLinesRef.current = target;
|
||||
// Pad at the top so the newest line stays pinned to the bottom.
|
||||
const padded =
|
||||
lines.length < target
|
||||
? [...new Array(target - lines.length).fill(''), ...lines]
|
||||
: lines;
|
||||
const display = padded.join('\n');
|
||||
return (
|
||||
<Box paddingLeft={2}>
|
||||
<Text dimColor wrap="truncate">
|
||||
{display}
|
||||
</Text>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
if (!expanded) return null;
|
||||
|
||||
return (
|
||||
<Box paddingLeft={2} flexDirection="column">
|
||||
|
||||
@@ -523,7 +523,7 @@ describe('<ToolGroupMessage />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Transcript full-detail mode must NOT be short-circuited by the
|
||||
// Full-detail mode must NOT be short-circuited by the
|
||||
// memory-only / pure-parallel-agent early returns (which run before the
|
||||
// forceExpandAll computation). Each tool must render in full.
|
||||
describe('fullDetail bypasses compact early returns', () => {
|
||||
|
||||
@@ -150,7 +150,7 @@ interface ToolGroupMessageProps {
|
||||
memoryReadCount?: number;
|
||||
isUserInitiated?: boolean;
|
||||
/**
|
||||
* Transcript full-detail mode (Ctrl+O). When true, force `forceExpandAll`
|
||||
* Full-detail mode (Ctrl+O). When true, force `forceExpandAll`
|
||||
* (skip the type-based partition so every tool renders individually), pass
|
||||
* `forceShowResult=true` to each `ToolMessage`, and lift the per-tool
|
||||
* terminal-height truncation. Default false (main view keeps the #5661
|
||||
@@ -283,7 +283,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
// header's "N · done/N" honest, and `availableTerminalHeight` is a hard cap
|
||||
// backstop for degenerate cases (many agents finishing at once).
|
||||
//
|
||||
// Skipped in transcript full-detail mode (fullDetail) so every agent
|
||||
// Skipped in full-detail mode (fullDetail) so every agent
|
||||
// falls through to its own full ToolMessage instead of the dense panel.
|
||||
if (
|
||||
!fullDetail &&
|
||||
@@ -332,7 +332,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
// Memory-only groups get their own compact rendering with read/write
|
||||
// counts. Check BEFORE the partition logic so they aren't routed through
|
||||
// the collapsible/non-collapsible split. Skipped in transcript full-detail
|
||||
// the collapsible/non-collapsible split. Skipped in full-detail
|
||||
// mode (fullDetail) so each memory op renders as its own full ToolMessage
|
||||
// rather than collapsing to the "Recalled/Wrote N memories" badge.
|
||||
const allMemOpsComplete =
|
||||
@@ -367,7 +367,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
|
||||
// Force-expand ALL tools individually when the user must interact or
|
||||
// must see full details: confirmation prompts, errors, user-initiated
|
||||
// batches, focused shells, terminal subagents. Transcript full-detail
|
||||
// batches, focused shells, terminal subagents. Full-detail
|
||||
// mode (fullDetail) also forces it so every tool renders individually
|
||||
// instead of collapsing read/search into a partition summary.
|
||||
const hasTerminalSubagent = inlineToolCalls.some(isTerminalSubagentTool);
|
||||
@@ -454,7 +454,7 @@ export const ToolGroupMessage: React.FC<ToolGroupMessageProps> = ({
|
||||
}
|
||||
const countOneLineToolCalls =
|
||||
nonCollapsibleTools.length - countToolCallsWithResults;
|
||||
// In transcript full-detail mode, lift the per-tool height truncation so
|
||||
// In full-detail mode, lift the per-tool height truncation so
|
||||
// each tool's output renders in full (combined with forceShowResult below).
|
||||
const availableTerminalHeightPerToolMessage = fullDetail
|
||||
? undefined
|
||||
|
||||
@@ -803,7 +803,7 @@ export const ToolMessage: React.FC<ToolMessageProps> = ({
|
||||
renderOutputAsMarkdown = false;
|
||||
}
|
||||
|
||||
// §4.9: in transcript full-detail mode, collapsible tools (read/search/list)
|
||||
// §4.9: in full-detail mode, collapsible tools (read/search/list)
|
||||
// swap the summary `resultDisplay` for the complete `detailedDisplay` derived
|
||||
// from the persisted functionResponse. Only a non-empty string detail
|
||||
// qualifies; everything else (and all main-view rendering) keeps the summary.
|
||||
|
||||
@@ -7,7 +7,12 @@
|
||||
import { createContext, useContext } from 'react';
|
||||
|
||||
export interface ThoughtExpandedValue {
|
||||
/** Alt+T global toggle — expands every thinking block at once. */
|
||||
/**
|
||||
* Ctrl+O / Alt+T global toggle. Despite the name this is the app-wide
|
||||
* full-detail switch: it force-expands every thinking block AND every tool
|
||||
* group (untruncating tool results), not thinking alone. `MainContent` reads
|
||||
* it and forwards it to each `HistoryItemDisplay` as `fullDetail`.
|
||||
*/
|
||||
allExpanded: boolean;
|
||||
/**
|
||||
* Head ids of thoughts the user expanded individually (by clicking the
|
||||
|
||||
@@ -149,7 +149,7 @@ export function useMouseEvents(
|
||||
// with. On the non-VP main screen, holding mouse tracking just hijacks the
|
||||
// wheel (Terminal.app diverts it away from scrollback), so by DEFAULT mouse
|
||||
// tracking is denied outside VP. Surfaces that legitimately consume the wheel
|
||||
// pass `bypassVpGate` to opt in. This keeps the non-VP transcript scrollable
|
||||
// pass `bypassVpGate` to opt in. This keeps the non-VP main screen scrollable
|
||||
// no matter how many click/hover subscribers are added later.
|
||||
const settings = useContext(SettingsContext);
|
||||
const isVpMode = useVirtualViewport(settings?.merged.ui?.useTerminalBuffer);
|
||||
@@ -161,9 +161,9 @@ export function useMouseEvents(
|
||||
// Never write SGR mouse-mode escapes (?1002h ?1006h) unless stdout is a TTY.
|
||||
// `isRawModeSupported` only reflects stdin; with stdout piped/redirected
|
||||
// (`qwen | tee log`) an active, raw-mode-capable surface — e.g. the non-TTY
|
||||
// transcript's focused ScrollableList (`bypassVpGate`) — would otherwise emit
|
||||
// raw control bytes into the captured output. Mirrors AlternateScreen's
|
||||
// `process.stdout.isTTY` guard so the non-TTY fallback stays byte-clean.
|
||||
// focused ScrollableList (`bypassVpGate`) — would otherwise emit
|
||||
// raw control bytes into the captured output. Mirrors the repo-wide
|
||||
// `process.stdout.isTTY` convention so the non-TTY fallback stays byte-clean.
|
||||
const enabled =
|
||||
isActive && isRawModeSupported && vpGateOpen && Boolean(stdout.isTTY);
|
||||
|
||||
|
||||
@@ -99,8 +99,7 @@ describe('keyMatchers', () => {
|
||||
[Command.SCROLL_HOME]: (key: Key) => key.ctrl && key.name === 'home',
|
||||
[Command.SCROLL_END]: (key: Key) => key.ctrl && key.name === 'end',
|
||||
[Command.TOGGLE_THINKING_EXPANDED]: (key: Key) =>
|
||||
key.meta && key.name === 't',
|
||||
[Command.TOGGLE_TRANSCRIPT]: (key: Key) => key.ctrl && key.name === 'o',
|
||||
(key.ctrl && key.name === 'o') || (key.meta && key.name === 't'),
|
||||
};
|
||||
|
||||
// Test data for each command with positive and negative test cases
|
||||
@@ -462,13 +461,16 @@ describe('keyMatchers', () => {
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_THINKING_EXPANDED,
|
||||
positive: [createKey('t', { meta: true })],
|
||||
negative: [createKey('t'), createKey('t', { ctrl: true })],
|
||||
},
|
||||
{
|
||||
command: Command.TOGGLE_TRANSCRIPT,
|
||||
positive: [createKey('o', { ctrl: true })],
|
||||
negative: [createKey('o'), createKey('o', { meta: true })],
|
||||
positive: [
|
||||
createKey('t', { meta: true }),
|
||||
createKey('o', { ctrl: true }),
|
||||
],
|
||||
negative: [
|
||||
createKey('t'),
|
||||
createKey('t', { ctrl: true }),
|
||||
createKey('o'),
|
||||
createKey('o', { meta: true }),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
sanitizeSensitiveText,
|
||||
sliceTextByVisualHeight,
|
||||
truncateToWidth,
|
||||
wrapToVisualLines,
|
||||
} from './textUtils.js';
|
||||
|
||||
describe('textUtils', () => {
|
||||
@@ -398,49 +397,3 @@ describe('textUtils', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('visual row counting agrees between wrap and slice', () => {
|
||||
// Both functions are documented as measuring visual rows at a given width,
|
||||
// and callers mix them (scroll offsets, pending-render height). They
|
||||
// disagreed on anything `string-width` reports as zero width, because only
|
||||
// one of them clamped the per-character width to 1.
|
||||
// `hiddenLinesCount + visible` only recovers the true row count when the
|
||||
// text actually overflows `visible`, so every case below is chosen to.
|
||||
const rowsFromSlice = (text: string, width: number): number => {
|
||||
const visible = 3;
|
||||
return (
|
||||
sliceTextByVisualHeight(text, visible, width).hiddenLinesCount + visible
|
||||
);
|
||||
};
|
||||
|
||||
it.each([
|
||||
['tabs', '\t'.repeat(50)],
|
||||
['combining marks', '́'.repeat(50)],
|
||||
['zero-width joiners', ''.repeat(50)],
|
||||
['a letter then combining marks', 'e' + '́'.repeat(49)],
|
||||
])('agrees on a run of %s', (_label, text) => {
|
||||
expect(wrapToVisualLines(text, 10).length).toBe(rowsFromSlice(text, 10));
|
||||
});
|
||||
|
||||
// Guards against over-correcting: ordinary and wide characters were always
|
||||
// consistent and must stay so. These pass before and after.
|
||||
it.each([
|
||||
['ascii', 'a'.repeat(50), 10],
|
||||
['wide CJK', '漢'.repeat(25), 10],
|
||||
['mixed', 'ab漢cd'.repeat(10), 10],
|
||||
])('still agrees on %s', (_label, text, width) => {
|
||||
expect(wrapToVisualLines(text, width).length).toBe(
|
||||
rowsFromSlice(text, width),
|
||||
);
|
||||
});
|
||||
|
||||
it('still wraps a string shorter than the width to one row', () => {
|
||||
expect(wrapToVisualLines('abc', 10)).toEqual(['abc']);
|
||||
});
|
||||
|
||||
it('counts a run of tabs as more than one row', () => {
|
||||
// The concrete regression: 50 zero-width characters at width 10 used to
|
||||
// wrap to a single row.
|
||||
expect(wrapToVisualLines('\t'.repeat(50), 10).length).toBe(5);
|
||||
});
|
||||
});
|
||||
@@ -275,53 +275,6 @@ export function sliceTextByVisualHeight(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap text into the visual rows it occupies at `width` columns, accounting
|
||||
* for both explicit newlines and code-point-width-aware soft wrapping. Unlike
|
||||
* `sliceTextByVisualHeight` (which keeps only a head/tail window), this returns
|
||||
* every visual row, so callers that scroll an arbitrary offset can slice the
|
||||
* rows the user actually sees.
|
||||
*/
|
||||
export function wrapToVisualLines(text: string, width: number): string[] {
|
||||
if (width <= 0) {
|
||||
return [''];
|
||||
}
|
||||
const visualLines: string[] = [];
|
||||
for (const logicalLine of text.split('\n')) {
|
||||
if (logicalLine === '') {
|
||||
visualLines.push('');
|
||||
continue;
|
||||
}
|
||||
let currentLine = '';
|
||||
let currentWidth = 0;
|
||||
for (const char of logicalLine) {
|
||||
// Clamped to 1, matching sliceTextByVisualHeight. `string-width` reports
|
||||
// 0 for TAB, ZWJ and combining marks, so without this a run of them was
|
||||
// charged nothing and the whole run counted as a single row: 50 tabs at
|
||||
// width 10 came back as 1 row here and 5 there, for the same input. Any
|
||||
// caller mixing the two -- scroll offsets, pending-render height -- then
|
||||
// disagreed with itself. Erring high is the safe direction for a
|
||||
// terminal: reserving a row too many costs a blank line, while counting
|
||||
// one too few overflows the region and pushes content off screen.
|
||||
const charWidth = Math.max(getCachedStringWidth(char), 1);
|
||||
if (currentWidth + charWidth > width && currentWidth > 0) {
|
||||
visualLines.push(currentLine);
|
||||
currentLine = '';
|
||||
currentWidth = 0;
|
||||
}
|
||||
currentLine += char;
|
||||
currentWidth += charWidth;
|
||||
}
|
||||
if (currentLine) {
|
||||
visualLines.push(currentLine);
|
||||
}
|
||||
}
|
||||
if (visualLines.length === 0) {
|
||||
visualLines.push('');
|
||||
}
|
||||
return visualLines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the string width cache
|
||||
*/
|
||||
|
||||
Reference in new issue
Block a user