From 1a456e3ea33e0e99596b447d8428088da5bbd7e9 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Thu, 13 Aug 2026 23:34:16 +0800 Subject: [PATCH 1/2] feat(terminal): add per-tab auto-fold toggle to block menu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fold store already exposed a shouldAutoFold hook; wire a per-tab $state into it so users can temporarily suspend auto-folding from the command-block right-click menu ("临时关闭自动折叠"). Only affects future output — existing folds stay untouched and manual unfold still works (the two are orthogonal). Per-tab isolation comes for free from the keyed TerminalPane instance; the toggle survives tab switches and reconnects, resetting only when the tab closes. --- src/lib/components/TerminalPane.svelte | 13 ++++++++++++- src/lib/i18n/locales/en.ts | 2 ++ src/lib/i18n/locales/zh.ts | 2 ++ 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/src/lib/components/TerminalPane.svelte b/src/lib/components/TerminalPane.svelte index c47a4e65..0f0d6411 100644 --- a/src/lib/components/TerminalPane.svelte +++ b/src/lib/components/TerminalPane.svelte @@ -224,6 +224,11 @@ let paintScheduler: PaintScheduler | undefined; let paintTick = $state(0); let isAltBuffer = $state(false); + // Per-tab: when true the fold store stops auto-folding new output (see + // shouldAutoFold below). Existing folds stay untouched; flip back to fold + // future output again. Survives reconnect and tab switches; resets only + // when the tab closes (this keyed component instance is destroyed). + let autoFoldDisabled = $state(false); function schedulePaintTick() { paintScheduler?.schedule(); @@ -536,6 +541,12 @@ disabled: n === 0 || !aiConfigured, action: () => sendBlocksToAi(targets), }, + { + label: t(autoFoldDisabled + ? "terminal.block.menu.enable_auto_fold" + : "terminal.block.menu.disable_auto_fold"), + action: () => { autoFoldDisabled = !autoFoldDisabled; }, + }, ], }; } @@ -1644,7 +1655,7 @@ foldStore = createFoldStore(terminal, blockTracker, { maxVisibleLines: commandBlockMaxLines, maxCachedLines: commandBlockFoldCacheLines(commandBlockMaxLines), - shouldAutoFold: () => app.commandBlockBar(), + shouldAutoFold: () => app.commandBlockBar() && !autoFoldDisabled, }); paintScheduler = createPaintScheduler({ shouldPaint: () => app.commandBlockBar() && !isAltBuffer, diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 0665e4c9..3a09ae7e 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -230,6 +230,8 @@ const en = { "terminal.block.menu.uncolor": "Remove color", "terminal.block.menu.send_ai": "Send to AI", "terminal.block.menu.send_ai_n": "Send {n} blocks to AI", + "terminal.block.menu.disable_auto_fold": "Disable auto-fold (this tab)", + "terminal.block.menu.enable_auto_fold": "Enable auto-fold", "ai.mobile.hint": "Tip: rotate to landscape for the AI panel. Tools download_file / analyze_locally are unavailable on this build.", "ai.no_session": "Connect to a terminal before opening the AI panel.", "settings.section.cli": "CLI Tool", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index cbd4345d..916f105b 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -232,6 +232,8 @@ const zh: Messages = { "terminal.block.menu.uncolor": "取消染色", "terminal.block.menu.send_ai": "发送到 AI", "terminal.block.menu.send_ai_n": "发送 {n} 块到 AI", + "terminal.block.menu.disable_auto_fold": "临时关闭自动折叠(当前标签)", + "terminal.block.menu.enable_auto_fold": "开启自动折叠", "ai.mobile.hint": "建议横屏使用 AI 面板。本机型不支持 download_file / analyze_locally 两个工具。", "ai.no_session": "请先连接到一个终端再打开 AI 面板。", "settings.section.cli": "CLI 工具", From a7cfb51a48fd2b0a4063c2b811a4fe77305a4102 Mon Sep 17 00:00:00 2001 From: shihuili1218 Date: Fri, 14 Aug 2026 10:48:24 +0800 Subject: [PATCH 2/2] fix(terminal): preserve folds through resize while auto-fold is disabled CodeRabbit review on #229: routing autoFoldDisabled through shouldAutoFold also gated enforceAutoFold, so disabling auto-fold skipped the reflow re-application and a resize afterwards lost all existing folds. Split the two concerns in FoldStoreOptions: shouldAutoFold gates everything (bar on/off); shouldFoldNewOutput gates only incremental folding of new output. enforceAutoFold (resize/reflow restoration) ignores shouldFoldNewOutput, so existing folds survive a resize while the per-tab toggle suspends future folding. Also align the enable/disable menu labels (both note per-tab scope) and add regression tests for both paths. --- src/lib/components/TerminalPane.svelte | 5 ++- src/lib/i18n/locales/en.ts | 2 +- src/lib/i18n/locales/zh.ts | 2 +- src/lib/terminal/folds.test.ts | 50 ++++++++++++++++++++++++++ src/lib/terminal/folds.ts | 16 +++++++-- 5 files changed, 70 insertions(+), 5 deletions(-) diff --git a/src/lib/components/TerminalPane.svelte b/src/lib/components/TerminalPane.svelte index 0f0d6411..5b5b7b84 100644 --- a/src/lib/components/TerminalPane.svelte +++ b/src/lib/components/TerminalPane.svelte @@ -1655,7 +1655,10 @@ foldStore = createFoldStore(terminal, blockTracker, { maxVisibleLines: commandBlockMaxLines, maxCachedLines: commandBlockFoldCacheLines(commandBlockMaxLines), - shouldAutoFold: () => app.commandBlockBar() && !autoFoldDisabled, + shouldAutoFold: () => app.commandBlockBar(), + // Per-tab "disable auto-fold" suspends folding of NEW output only; + // enforceAutoFold (resize/reflow) still restores existing folds. + shouldFoldNewOutput: () => !autoFoldDisabled, }); paintScheduler = createPaintScheduler({ shouldPaint: () => app.commandBlockBar() && !isAltBuffer, diff --git a/src/lib/i18n/locales/en.ts b/src/lib/i18n/locales/en.ts index 3a09ae7e..00cfaf7f 100644 --- a/src/lib/i18n/locales/en.ts +++ b/src/lib/i18n/locales/en.ts @@ -231,7 +231,7 @@ const en = { "terminal.block.menu.send_ai": "Send to AI", "terminal.block.menu.send_ai_n": "Send {n} blocks to AI", "terminal.block.menu.disable_auto_fold": "Disable auto-fold (this tab)", - "terminal.block.menu.enable_auto_fold": "Enable auto-fold", + "terminal.block.menu.enable_auto_fold": "Enable auto-fold (this tab)", "ai.mobile.hint": "Tip: rotate to landscape for the AI panel. Tools download_file / analyze_locally are unavailable on this build.", "ai.no_session": "Connect to a terminal before opening the AI panel.", "settings.section.cli": "CLI Tool", diff --git a/src/lib/i18n/locales/zh.ts b/src/lib/i18n/locales/zh.ts index 916f105b..1b92ca20 100644 --- a/src/lib/i18n/locales/zh.ts +++ b/src/lib/i18n/locales/zh.ts @@ -233,7 +233,7 @@ const zh: Messages = { "terminal.block.menu.send_ai": "发送到 AI", "terminal.block.menu.send_ai_n": "发送 {n} 块到 AI", "terminal.block.menu.disable_auto_fold": "临时关闭自动折叠(当前标签)", - "terminal.block.menu.enable_auto_fold": "开启自动折叠", + "terminal.block.menu.enable_auto_fold": "开启自动折叠(当前标签)", "ai.mobile.hint": "建议横屏使用 AI 面板。本机型不支持 download_file / analyze_locally 两个工具。", "ai.no_session": "请先连接到一个终端再打开 AI 面板。", "settings.section.cli": "CLI 工具", diff --git a/src/lib/terminal/folds.test.ts b/src/lib/terminal/folds.test.ts index aed16851..83f5592f 100644 --- a/src/lib/terminal/folds.test.ts +++ b/src/lib/terminal/folds.test.ts @@ -911,6 +911,56 @@ describe("FoldStore — automatic command-block limit", () => { expect(f.lineContents().slice(0, 4)).toEqual(["L0", "L8", "L9", "L10"]); }); + it("does not fold new output while new-output folding is disabled (bar still on)", () => { + const f = fakeTerm({ rows: 12, initialLines: 12, cursorY: 10 }); + const start = f.makeMarker(0); + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, start, null)]), + { maxVisibleLines: 3, maxCachedLines: 8, shouldFoldNewOutput: () => false }, + ); + + f.fireWriteParsed(); + + // shouldFoldNewOutput=false suspends incremental folding; the block stays + // fully visible even though it exceeds maxVisibleLines. + expect(store.getFold(1)).toBeUndefined(); + expect(f.lineContents().slice(0, 4)).toEqual(["L0", "L1", "L2", "L3"]); + }); + + it("keeps an existing fold through resize/reflow while new-output folding is disabled", () => { + const f = fakeTerm({ rows: 12, initialLines: 12, cursorY: 10 }); + const start = f.makeMarker(0); + let newOutputEnabled = true; + const store = createFoldStore( + f.term, + fakeTracker([makeBlock(1, start, null)]), + { + maxVisibleLines: 3, + maxCachedLines: 8, + shouldFoldNewOutput: () => newOutputEnabled, + }, + ); + + // 1. Auto-fold on: a block over the limit gets prefix-folded. + f.fireWriteParsed(); + expect(store.getFold(1)).toMatchObject({ kind: "prefix", count: 7 }); + + // 2. User disables auto-fold for this tab (future output only). + newOutputEnabled = false; + + // 3. fitTerminal resize sequence: unfoldAll (so saved lines reflow at the + // new width) then enforceAutoFold. Disabling new-output folding must NOT + // block the reflow re-application, or the existing fold is lost. + store.unfoldAll(); + expect(store.getFold(1)).toBeUndefined(); + store.enforceAutoFold(); + + // 4. The existing fold is restored. + expect(store.getFold(1)).toMatchObject({ kind: "prefix", count: 7 }); + expect(f.lineContents().slice(0, 4)).toEqual(["L0", "L8", "L9", "L10"]); + }); + it("folds during a large parse batch before xterm can trim the block marker", () => { const f = fakeTerm({ rows: 40, initialLines: 40, cursorY: 3 }); const start = f.makeMarker(0); diff --git a/src/lib/terminal/folds.ts b/src/lib/terminal/folds.ts index e8b3e73d..215bbb4d 100644 --- a/src/lib/terminal/folds.ts +++ b/src/lib/terminal/folds.ts @@ -80,6 +80,10 @@ export interface FoldStoreOptions { maxCachedLines?: number; /** Visual block controls must be available before automatic folding hides rows. */ shouldAutoFold?: () => boolean; + /** Suspend folding of *new* output only. enforceAutoFold (the resize/reflow + * re-application) is unaffected, so existing folds survive a resize while a + * per-tab "disable auto-fold" toggle suspends folding of future output. */ + shouldFoldNewOutput?: () => boolean; } /** xterm 默认 attr(fg=0,bg=0),与 DEFAULT_ATTR_DATA 等价。getBlankLine 必填。 */ @@ -316,6 +320,14 @@ export function createFoldStore( && term.buffer.active.type === "normal"; } + /** Incremental folding of newly arriving output. Stricter than canAutoFold: + * also honors shouldFoldNewOutput so a per-tab "disable auto-fold" toggle + * suspends folding of future output WITHOUT disabling enforceAutoFold, + * which must still restore folds after a resize/reflow. */ + function canFoldNewOutput(): boolean { + return canAutoFold() && options.shouldFoldNewOutput?.() !== false; + } + function foldBlockOverflow(block: CommandBlock, minimumExcess: number): void { if (maxVisibleLines === null || block.start.isDisposed) return; if (folds.get(block.id)?.kind === "full") return; @@ -332,13 +344,13 @@ export function createFoldStore( } function foldActiveOverflow(minimumExcess: number): void { - if (!canAutoFold()) return; + if (!canFoldNewOutput()) return; const block = tracker.blocks[tracker.blocks.length - 1]; if (block?.end === null) foldBlockOverflow(block, minimumExcess); } function foldRecentOverflow(): void { - if (!canAutoFold()) return; + if (!canFoldNewOutput()) return; const blocks = tracker.blocks; // Prompt detection runs before this listener and may close the previous // block while opening a new prompt block in the same parse batch.