zidane 6.2.13 → 6.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/dist/acp-cli.js +2 -2
  2. package/dist/{anthropic-BYsc6qWj.js → anthropic-CTAXBVsj.js} +2 -2
  3. package/dist/anthropic-CTAXBVsj.js.map +1 -0
  4. package/dist/{auth-Bb479DFB.js → auth-BCpEbzvO.js} +2 -2
  5. package/dist/{auth-Bb479DFB.js.map → auth-BCpEbzvO.js.map} +1 -1
  6. package/dist/chat/pure.d.ts +3 -3
  7. package/dist/chat/pure.js +3 -2
  8. package/dist/chat.d.ts +3 -3
  9. package/dist/chat.js +5 -4
  10. package/dist/chat.js.map +1 -1
  11. package/dist/history-search-C4bk1Wz_.js +486 -0
  12. package/dist/history-search-C4bk1Wz_.js.map +1 -0
  13. package/dist/history-search-worker.d.ts +1 -0
  14. package/dist/history-search-worker.js +86 -0
  15. package/dist/history-search-worker.js.map +1 -0
  16. package/dist/index.js +1 -1
  17. package/dist/providers/anthropic.js +1 -1
  18. package/dist/providers.js +1 -1
  19. package/dist/{tool-formatters-BSwVkSX2.d.ts → tool-formatters-DdgS-xE2.d.ts} +90 -4
  20. package/dist/tool-formatters-DdgS-xE2.d.ts.map +1 -0
  21. package/dist/transcript-anchors-DKjXpQ0h.d.ts.map +1 -1
  22. package/dist/{transcript-anchors-yogJHuBW.js → transcript-anchors-DZ-8BF4p.js} +3 -3
  23. package/dist/transcript-anchors-DZ-8BF4p.js.map +1 -0
  24. package/dist/tui.d.ts +52 -10
  25. package/dist/tui.d.ts.map +1 -1
  26. package/dist/tui.js +1340 -246
  27. package/dist/tui.js.map +1 -1
  28. package/dist/{turn-operations-CXjRaltC.js → turn-operations-CokV32wf.js} +82 -11
  29. package/dist/turn-operations-CokV32wf.js.map +1 -0
  30. package/dist/{turn-operations-CjkgQKmR.d.ts → turn-operations-sE-Sli9H.d.ts} +2 -2
  31. package/dist/{turn-operations-CjkgQKmR.d.ts.map → turn-operations-sE-Sli9H.d.ts.map} +1 -1
  32. package/package.json +1 -1
  33. package/dist/anthropic-BYsc6qWj.js.map +0 -1
  34. package/dist/tool-formatters-BSwVkSX2.d.ts.map +0 -1
  35. package/dist/transcript-anchors-yogJHuBW.js.map +0 -1
  36. package/dist/turn-operations-CXjRaltC.js.map +0 -1
package/dist/tui.js CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as useMcpAuthState, $n as buildPlanSystem, $t as restoreChainFallbacks, A as runOAuthLogin, Bt as createDiscoverySlot, Ct as SETTINGS_CHOICES, D as formatPathForCwd, Dt as clampFps, Et as SettingsProvider, G as buildVisibleMcpRows, Gt as useConfig, Ht as useDiscovery, J as createFileMcpCredentialStore, Jt as buildRunModel, K as indexOfServerRow, Kt as resolveConfig, L as loadMcpToolsCache, M as buildMcpServers, Mt as resolveTheme, O as fetchOAuthRedirect, Ot as useSettings, P as discoverProjectMcps, Q as useMcpAuthDispatch, Qn as buildBuildSystem, Qt as providerKeySetChanged, R as mcpToolsCachePath, St as SETTINGS_CATEGORIES, T as suggestSafelistEntries, Tn as accentColor, Tt as SETTINGS_TOGGLES, U as useMcpToolToggleMap, Ut as useDiscoveryOptional, V as subscribeMcpToolsCache, Vn as useActiveTodos, Vt as DiscoveryProvider, Wt as ConfigProvider, Xt as chainsEqual, Yt as chainPositionOf, Z as McpAuthProvider, Zt as filterChainFallbacks, _ as useSafeModeQueue, _n as discoverAgentsMd, _r as toolCallPreview, _t as buildHints, a as useSurfaces, an as bootTick, at as buildResumedToolResultsTurn, b as getSafelist, br as usageByModel, bt as useEnabledToggleSet, c as useStreamBuffer, cr as lastContextSizeFromTurns, ct as makeRequestInteraction, d as discoverProjectSkills, dr as marginTopFor, dt as useInteractionsActions, er as envSection, et as getMcpAuthStatus, ft as useInteractionsQueue, g as useSafeModeActions, gn as chatAutoCompactBehavior, gt as truncateTrailing, h as SafeModeProvider, hr as sumRunCosts, ht as hintsLength, i as useSelectStyle, ir as compactSummaryEvent, it as PRESENT_PLAN_TOOL, j as supportsOAuth, jn as TODO_STATUS_GLYPHS, jt as resolveChipColor, k as oauthUsesManualCodePaste, l as buildSkillsConfig, lr as listSessionMeta, lt as pendingInteractionsFromTurns, m as writeSessionExport, mr as stripSpawnTokensLine, mt as clipHintsToWidth, n as ThemeProvider, nn as useCompletion, nr as createChatTurnsReadTool, nt as ASK_USER_TOOL, on as buildUpdateHint, or as deriveSessionTitle, ot as createInteractionTools, pr as serverToolResultSummary, pt as EMPTY_HINTS, q as parentServerName, qt as resolveStoragePaths, r as useColors, rn as tryOpenBrowser, rt as InteractionsProvider, s as useTheme, sn as useUpdateCheck, sr as eventsFromTurns, t as computeTurnAnchors, tn as toggleChainEntry, tr as chatReferenceTokensForPrompt, u as defaultSkillScanPaths, ur as loadState, vr as toolResultText, wt as SETTINGS_INPUTS, x as isOnSafelist, xt as DEFAULT_SETTINGS, y as addToSafelist, yr as updateToolEventOutcomes, yt as listProjectFiles, z as refreshMcpToolsCatalog } from "./transcript-anchors-yogJHuBW.js";
1
+ import { $ as useMcpAuthState, $n as buildPlanSystem, $t as restoreChainFallbacks, A as runOAuthLogin, Bt as createDiscoverySlot, Ct as SETTINGS_CHOICES, D as formatPathForCwd, Dt as clampFps, Et as SettingsProvider, G as buildVisibleMcpRows, Gt as useConfig, Ht as useDiscovery, J as createFileMcpCredentialStore, Jt as buildRunModel, K as indexOfServerRow, Kt as resolveConfig, L as loadMcpToolsCache, M as buildMcpServers, Mt as resolveTheme, O as fetchOAuthRedirect, Ot as useSettings, P as discoverProjectMcps, Q as useMcpAuthDispatch, Qn as buildBuildSystem, Qt as providerKeySetChanged, R as mcpToolsCachePath, St as SETTINGS_CATEGORIES, T as suggestSafelistEntries, Tn as accentColor, Tt as SETTINGS_TOGGLES, U as useMcpToolToggleMap, Ut as useDiscoveryOptional, V as subscribeMcpToolsCache, Vn as useActiveTodos, Vt as DiscoveryProvider, Wt as ConfigProvider, Xt as chainsEqual, Yt as chainPositionOf, Z as McpAuthProvider, Zt as filterChainFallbacks, _ as useSafeModeQueue, _n as discoverAgentsMd, _r as toolCallPreview, _t as buildHints, a as useSurfaces, an as bootTick, at as buildResumedToolResultsTurn, b as getSafelist, br as usageByModel, bt as useEnabledToggleSet, c as useStreamBuffer, cr as lastContextSizeFromTurns, ct as makeRequestInteraction, d as discoverProjectSkills, dr as marginTopFor, dt as useInteractionsActions, er as envSection, et as getMcpAuthStatus, ft as useInteractionsQueue, g as useSafeModeActions, gn as chatAutoCompactBehavior, gt as truncateTrailing, h as SafeModeProvider, hr as sumRunCosts, ht as hintsLength, i as useSelectStyle, ir as compactSummaryEvent, it as PRESENT_PLAN_TOOL, j as supportsOAuth, jn as TODO_STATUS_GLYPHS, jt as resolveChipColor, k as oauthUsesManualCodePaste, l as buildSkillsConfig, lr as listSessionMeta, lt as pendingInteractionsFromTurns, m as writeSessionExport, mr as stripSpawnTokensLine, mt as clipHintsToWidth, n as ThemeProvider, nn as useCompletion, nr as createChatTurnsReadTool, nt as ASK_USER_TOOL, on as buildUpdateHint, or as deriveSessionTitle, ot as createInteractionTools, pr as serverToolResultSummary, pt as EMPTY_HINTS, q as parentServerName, qt as resolveStoragePaths, r as useColors, rn as tryOpenBrowser, rt as InteractionsProvider, s as useTheme, sn as useUpdateCheck, sr as eventsFromTurns, t as computeTurnAnchors, tn as toggleChainEntry, tr as chatReferenceTokensForPrompt, u as defaultSkillScanPaths, ur as loadState, vr as toolResultText, wt as SETTINGS_INPUTS, x as isOnSafelist, xt as DEFAULT_SETTINGS, y as addToSafelist, yr as updateToolEventOutcomes, yt as listProjectFiles, z as refreshMcpToolsCatalog } from "./transcript-anchors-DZ-8BF4p.js";
2
2
  import { $ as composeExtensionPreset, B as ExtensionInstallError, Bt as ensureKeybindingsFile, Et as createModelSearchTool, H as installExtensionFromSpec, Ht as groupBindings, J as createExtensionHud, K as removeInstalledExtension, P as summaryToTurn, St as tailTruncate, Ut as keybindingsPath, Vt as formatBindingForDisplay, Wt as matchesBinding, X as discoverAndBuildExtensionRegistry, Y as registerExtensionHostModules, Yt as resolveExtensionKeybindings, _ as buildPostCompactAttachments, c as applyCompactSummaryCutoff, dn as sanitizeExtensionConfigActionPatch, en as inlineLoadedExtension, et as disposeExtensionRegistry, f as cleanupPersistedSession, g as resolveTasksDir, h as resolvePersistDir, hn as effectiveContextWindow, ht as isSpawnTool, it as createExtensionHostRuntimeBridge, lt as basic_default, m as resolveMcpWarningsDir, mn as OUTPUT_RESERVE_TOKENS, nn as parseExtensionScanPathEnv, on as coerceExtensionConfigInput, q as updateInstalledExtension, rn as scanExtensionDirectories, rt as partitionExtensionCatalog, t as createAgent, tt as filterExtensionRegistry, un as resolveExtensionConfigValue, wt as looksBinary, x as compactConversation, y as selectFilesFromSession, z as createExtensionPromptBridge, zt as KEYBINDING_KEY_COL_WIDTH } from "./agent-DoEx_WD3.js";
3
3
  import { a as formatDuration, c as previewLine, i as fmtTokens, l as shortId, n as compactPath, o as formatTaskStatus, r as fmtCost, s as formatTaskSummary, t as ageString } from "./format-BF745pLL.js";
4
4
  import { n as appendCapped } from "./output-cap-kBUHBlnq.js";
@@ -16,10 +16,11 @@ import { a as generateStructuredData, c as shouldAutoGenerateTitle, f as command
16
16
  import { s as resolveCompletionSubmit } from "./completion-core-BRo86R_L.js";
17
17
  import { i as installSkillFromSpec, r as SkillInstallError } from "./skills-DKWqoaYH.js";
18
18
  import { i as ZIDANE_EXTENSION_SKILL_NAME, l as withZidaneExtensionSkill, o as extensionDirForCwd, r as EXTENSION_EDIT_METADATA_KEY, s as extensionScaffold, t as loginMcpServer } from "./login-Clf-R27F.js";
19
- import { C as restoreModelOptions, S as piIdOf, _ as modelOptionsFor, m as getContextWindow, p as enabledModelOptions, s as setProviderCredential, t as detectAuth, v as modelSupportsReasoning } from "./auth-Bb479DFB.js";
19
+ import { C as restoreModelOptions, S as piIdOf, _ as modelOptionsFor, m as getContextWindow, p as enabledModelOptions, s as setProviderCredential, t as detectAuth, v as modelSupportsReasoning } from "./auth-BCpEbzvO.js";
20
20
  import { createDockerContext } from "./contexts/docker.js";
21
21
  import { createTuiStore } from "./session/sqlite.js";
22
- import { $ as buildEditOutcomesAnnotation, A as blendHsl, B as turnSelectionOwnership, C as stripHtmlCommentsOutsideCode, E as uniqueSkillNamesFromReferences, F as isTurnHighlighted, H as buildContextualDiff, I as isUserBashEvent, J as filetypeFromPath, L as isVisible, M as BASH_CALL_ID_PREFIX, N as EDIT_TOOL_NAMES, O as createFilesCompletionProvider, P as isEditErrorResult, R as promptHistoryFromEvents, S as splitMarkdownCodeBlocks, T as createSkillsCompletionProvider, U as buildUnifiedDiff, W as clipDiffForRender, Y as previewEditPayload, Z as summarizeEditPayload, _ as filterModelCatalog, a as TOOL_DISPLAY, at as stripEditOutcomesAnnotation, b as chunkMarkdownForRender, d as finalizeStreamingMarkdown, f as finalizeStreamingMarkdownForOwner, g as buildModelCatalog, h as splitPromptSegments, i as turnAsText, it as rewriteMultiEditHeader, j as buildLinearRamp, lt as createChatsCompletionProvider, m as turnContextSize, n as deleteTurnSafely, nt as parseEditOutcomesFromResult, o as displayNameFor, ot as summarizeOutcomes, q as extractEditPayload, r as truncateTurnsAt, rt as resolveApprovalForPayload, s as formatToolCall, tt as mergeApprovalAndBodyOutcomes, u as capTranscriptEvents, v as indexOfEntry, x as normalizeMarkdownCodeFences, z as selectableTurnIds } from "./turn-operations-CXjRaltC.js";
22
+ import { $ as extractEditPayload, B as isVisible, C as chunkMarkdownForRender, E as stripHtmlCommentsOutsideCode, F as BASH_CALL_ID_PREFIX, G as streamEventSearchText, H as promptHistoryFromEvents, I as EDIT_TOOL_NAMES, J as buildContextualDiff, K as turnSelectionOwnership, L as isEditErrorResult, N as blendHsl, O as createSkillsCompletionProvider, P as buildLinearRamp, R as isTurnHighlighted, T as splitMarkdownCodeBlocks, U as selectableTurnIds, V as isVisibleDuringSearch, W as selectionAfterTurnRemoval, X as clipDiffForRender, Y as buildUnifiedDiff, _ as turnContextSize, a as TOOL_DISPLAY, at as buildEditOutcomesAnnotation, b as filterModelCatalog, ct as parseEditOutcomesFromResult, d as finalizeStreamingMarkdown, dt as stripEditOutcomesAnnotation, et as filetypeFromPath, f as finalizeStreamingMarkdownForOwner, ft as summarizeOutcomes, g as revealEarlierTranscriptEvents, h as replaceTranscriptTurnEvents, ht as createChatsCompletionProvider, i as turnAsText, j as createFilesCompletionProvider, k as uniqueSkillNamesFromReferences, lt as resolveApprovalForPayload, n as deleteTurnSafely, o as displayNameFor, p as hiddenEarlierTranscriptCount, r as truncateTurnsAt, rt as summarizeEditPayload, s as formatToolCall, st as mergeApprovalAndBodyOutcomes, tt as previewEditPayload, u as capTranscriptEvents, ut as rewriteMultiEditHeader, v as splitPromptSegments, w as normalizeMarkdownCodeFences, y as buildModelCatalog, z as isUserBashEvent } from "./turn-operations-CokV32wf.js";
23
+ import { a as searchHighlightRanges, c as searchSessionHistoryIndexAsync, i as highlightSegments, n as buildSessionSearchIndexAsync, r as filterSessionSearchCatalog, u as searchSessionIndexAsync } from "./history-search-C4bk1Wz_.js";
23
24
  import { basename, dirname, join, relative, resolve } from "node:path";
24
25
  import { Buffer } from "node:buffer";
25
26
  import * as fs from "node:fs";
@@ -1505,15 +1506,16 @@ const BASH_RESULT_MAX_LINES = 10;
1505
1506
  * same-turn events read as one continuous highlighted block instead of a
1506
1507
  * striped list.
1507
1508
  */
1508
- const EventLine = memo(({ event, previous, depthOffset = 0, selected = false, anchorId, hideChildLabel = false, settleCodeEpoch = 0, liveSpawn }) => {
1509
+ const EventLine = memo(({ event, previous, depthOffset = 0, selected = false, anchorId, hideChildLabel = false, settleCodeEpoch = 0, liveSpawn, searchQuery, searchTitleMatch = false }) => {
1509
1510
  const SURFACE = useSurfaces();
1510
1511
  const gap = marginTopFor(event, previous);
1512
+ const highlighted = selected;
1511
1513
  return /* @__PURE__ */ jsx$1("box", {
1512
1514
  id: anchorId,
1513
1515
  style: {
1514
- marginTop: selected ? 0 : gap,
1515
- paddingTop: selected ? gap : 0,
1516
- backgroundColor: selected ? SURFACE.selection : void 0,
1516
+ marginTop: 0,
1517
+ paddingTop: gap,
1518
+ backgroundColor: highlighted ? SURFACE.selection : SURFACE.background,
1517
1519
  alignSelf: "stretch",
1518
1520
  flexShrink: 0,
1519
1521
  flexDirection: "column"
@@ -1523,9 +1525,11 @@ const EventLine = memo(({ event, previous, depthOffset = 0, selected = false, an
1523
1525
  previous,
1524
1526
  depthOffset,
1525
1527
  hideChildLabel,
1526
- selected,
1528
+ selected: highlighted,
1527
1529
  settleCodeEpoch,
1528
- liveSpawn
1530
+ liveSpawn,
1531
+ searchQuery,
1532
+ searchTitleMatch
1529
1533
  })
1530
1534
  });
1531
1535
  });
@@ -1558,8 +1562,8 @@ function Footer({ hints, context, cost = null, status = null, compactionTokens =
1558
1562
  const timer = setInterval(() => setCompactTick((t) => t + 1), 1e3);
1559
1563
  return () => clearInterval(timer);
1560
1564
  }, [status]);
1561
- const compactLabel = status === "compacting" ? compactingLabel(compactionTokens, compactionStartedAt) : "";
1562
- const sW = status === "compacting" ? 2 + compactLabel.length : status === "asking" ? 3 : status ? 2 : 0;
1565
+ const statusLabel = status === "compacting" ? compactingLabel(compactionTokens, compactionStartedAt) : status === "searching" ? " Searching messages…" : "";
1566
+ const sW = status === "compacting" || status === "searching" ? 2 + statusLabel.length : status === "asking" ? 3 : status ? 2 : 0;
1563
1567
  if (hW + sW + (rightW > 0 ? rightW + 1 : 0) <= inner) return /* @__PURE__ */ jsxs("box", {
1564
1568
  style: {
1565
1569
  flexDirection: "row",
@@ -1571,7 +1575,7 @@ function Footer({ hints, context, cost = null, status = null, compactionTokens =
1571
1575
  /* @__PURE__ */ jsx$1(HintsText, { hints }),
1572
1576
  /* @__PURE__ */ jsx$1(FooterStatusIcon, {
1573
1577
  status,
1574
- compactLabel
1578
+ label: statusLabel
1575
1579
  }),
1576
1580
  /* @__PURE__ */ jsx$1("box", { style: { flexGrow: 1 } }),
1577
1581
  /* @__PURE__ */ jsx$1(RightStatus, {
@@ -1594,7 +1598,7 @@ function Footer({ hints, context, cost = null, status = null, compactionTokens =
1594
1598
  },
1595
1599
  children: [/* @__PURE__ */ jsx$1(HintsText, { hints: stackedHints }), /* @__PURE__ */ jsx$1(FooterStatusIcon, {
1596
1600
  status,
1597
- compactLabel
1601
+ label: statusLabel
1598
1602
  })]
1599
1603
  }), rightW > 0 && /* @__PURE__ */ jsxs("box", {
1600
1604
  style: {
@@ -1625,16 +1629,16 @@ function RightStatus({ cost, context }) {
1625
1629
  * compacting adds a short label because background compaction can otherwise
1626
1630
  * look like an idle spinner.
1627
1631
  */
1628
- function FooterStatusIcon({ status, compactLabel = "" }) {
1632
+ function FooterStatusIcon({ status, label = "" }) {
1629
1633
  const COLOR = useColors();
1630
1634
  if (!status) return null;
1631
1635
  const glyph = status === "asking" ? /* @__PURE__ */ jsx$1("span", { children: "❓" }) : status === "busy" ? /* @__PURE__ */ jsx$1(StatusSpinner, { color: COLOR.warn }) : /* @__PURE__ */ jsx$1(StatusSpinner, { color: COLOR.accent });
1632
1636
  return /* @__PURE__ */ jsxs("text", { children: [
1633
1637
  /* @__PURE__ */ jsx$1("span", { children: " " }),
1634
1638
  glyph,
1635
- status === "compacting" && /* @__PURE__ */ jsx$1("span", {
1639
+ label && /* @__PURE__ */ jsx$1("span", {
1636
1640
  fg: COLOR.accent,
1637
- children: compactLabel
1641
+ children: label
1638
1642
  })
1639
1643
  ] });
1640
1644
  }
@@ -1924,9 +1928,8 @@ function StatusSpinner({ color }) {
1924
1928
  children: useSpinnerFrame()
1925
1929
  });
1926
1930
  }
1927
- function Transcript({ events, settings, selectedTurnId = null, busy = false }) {
1931
+ function Transcript({ events, settings, selectedTurnId = null, busy = false, searchQuery, searchPending = false, onLoadEarlier }) {
1928
1932
  const COLOR = useColors();
1929
- const items = useMemo(() => partitionTranscript(events, settings), [events, settings]);
1930
1933
  const showThrobber = busy && settings.showThrobber;
1931
1934
  const codeSettle = useRef({
1932
1935
  wasBusy: false,
@@ -1949,8 +1952,48 @@ function Transcript({ events, settings, selectedTurnId = null, busy = false }) {
1949
1952
  codeSettle.current.wasBusy = busy;
1950
1953
  const throbberLabel = events.length > 0 && events[events.length - 1].kind === "thinking" ? "Thinking" : void 0;
1951
1954
  const ownership = useMemo(() => turnSelectionOwnership(events), [events]);
1955
+ const searchMatches = useMemo(() => {
1956
+ const direct = /* @__PURE__ */ new Set();
1957
+ const callIds = /* @__PURE__ */ new Set();
1958
+ const childIds = /* @__PURE__ */ new Set();
1959
+ if (!searchQuery) return {
1960
+ direct,
1961
+ callIds,
1962
+ childIds
1963
+ };
1964
+ const stack = [];
1965
+ for (const event of events) {
1966
+ if (event.kind === "spawn-start") stack.push(event);
1967
+ if (searchHighlightRanges(streamEventSearchText(event), searchQuery).length > 0) {
1968
+ direct.add(event);
1969
+ if (event.callId) callIds.add(event.callId);
1970
+ if ((event.depth ?? 0) > 0) {
1971
+ for (const parent of stack) if (parent.childId) childIds.add(parent.childId);
1972
+ }
1973
+ }
1974
+ if (event.kind === "spawn-end") {
1975
+ const index = stack.findLastIndex((parent) => parent.childId === event.childId);
1976
+ if (index >= 0) stack.splice(index);
1977
+ }
1978
+ }
1979
+ return {
1980
+ direct,
1981
+ callIds,
1982
+ childIds
1983
+ };
1984
+ }, [events, searchQuery]);
1985
+ const items = useMemo(() => partitionTranscript(events, settings, searchQuery ? (event) => searchMatches.direct.has(event) : void 0), [
1986
+ events,
1987
+ settings,
1988
+ searchQuery,
1989
+ searchMatches
1990
+ ]);
1952
1991
  const scrollboxRef = useRef(null);
1953
1992
  const rowVisibility = useRef(createRowVisibilityStore());
1993
+ const loadEarlierRef = useRef(onLoadEarlier);
1994
+ loadEarlierRef.current = onLoadEarlier;
1995
+ const earlierLoad = useRef(null);
1996
+ const earlierLoadArmed = useRef(false);
1954
1997
  useEffect(() => {
1955
1998
  const timer = setInterval(() => {
1956
1999
  const sb = scrollboxRef.current;
@@ -1959,9 +2002,45 @@ function Transcript({ events, settings, selectedTurnId = null, busy = false }) {
1959
2002
  top: sb.viewport.y,
1960
2003
  height: sb.viewport.height
1961
2004
  });
2005
+ const topThreshold = Math.max(4, Math.floor(sb.viewport.height / 4));
2006
+ if (sb.scrollTop > topThreshold) earlierLoadArmed.current = true;
2007
+ else if (earlierLoadArmed.current && !earlierLoad.current && loadEarlierRef.current && hiddenEarlierTranscriptCount(events) > 0) {
2008
+ earlierLoadArmed.current = false;
2009
+ const snapshot = {
2010
+ oldHeight: sb.scrollHeight,
2011
+ oldTop: sb.scrollTop
2012
+ };
2013
+ earlierLoad.current = snapshot;
2014
+ if (!loadEarlierRef.current()) {
2015
+ earlierLoad.current = null;
2016
+ earlierLoadArmed.current = true;
2017
+ }
2018
+ }
1962
2019
  }, 200);
1963
2020
  return () => clearInterval(timer);
1964
- }, []);
2021
+ }, [events]);
2022
+ useEffect(() => {
2023
+ const pending = earlierLoad.current;
2024
+ if (!pending) return;
2025
+ let cancelled = false;
2026
+ let attempts = 0;
2027
+ const restore = () => {
2028
+ if (cancelled) return;
2029
+ const sb = scrollboxRef.current;
2030
+ if (attempts < 5 && (!sb || sb.scrollHeight <= pending.oldHeight)) {
2031
+ attempts++;
2032
+ requestAnimationFrame(restore);
2033
+ return;
2034
+ }
2035
+ if (sb) sb.scrollTop = pending.oldTop + Math.max(0, sb.scrollHeight - pending.oldHeight);
2036
+ earlierLoad.current = null;
2037
+ };
2038
+ const handle = requestAnimationFrame(restore);
2039
+ return () => {
2040
+ cancelled = true;
2041
+ cancelAnimationFrame(handle);
2042
+ };
2043
+ }, [events]);
1965
2044
  const modal = useModalState();
1966
2045
  useKeyboard(useCallback((key) => {
1967
2046
  if (modal?.isOpen) return;
@@ -1993,36 +2072,56 @@ function Transcript({ events, settings, selectedTurnId = null, busy = false }) {
1993
2072
  }, [items, events]);
1994
2073
  useEffect(() => {
1995
2074
  if (!selectedTurnId) return;
1996
- const scrollbox = scrollboxRef.current;
1997
- if (!scrollbox) return;
1998
- const handle = requestAnimationFrame(() => {
2075
+ let handle = 0;
2076
+ let attempts = 0;
2077
+ const align = () => {
2078
+ const scrollbox = scrollboxRef.current;
2079
+ if (!scrollbox) return;
2080
+ const id = anchors.idByTurn.get(selectedTurnId);
2081
+ if (searchQuery && id) {
2082
+ const child = scrollbox.content.findDescendantById(id);
2083
+ if (child && scrollbox.scrollHeight > scrollbox.viewport.height) {
2084
+ scrollbox.scrollTop = child.y - scrollbox.content.y;
2085
+ return;
2086
+ }
2087
+ if (attempts++ < 5) handle = requestAnimationFrame(align);
2088
+ return;
2089
+ }
1999
2090
  const ownsLast = anchors.lastTurnId !== void 0 && ownership.get(anchors.lastTurnId) === selectedTurnId;
2000
2091
  if (selectedTurnId === anchors.lastTurnId || ownsLast) {
2001
2092
  scrollbox.scrollTop = scrollbox.scrollHeight;
2002
2093
  return;
2003
2094
  }
2004
- const id = anchors.idByTurn.get(selectedTurnId);
2005
2095
  if (id) scrollbox.scrollChildIntoView(id);
2006
- });
2096
+ };
2097
+ handle = requestAnimationFrame(align);
2007
2098
  return () => cancelAnimationFrame(handle);
2008
2099
  }, [
2009
2100
  selectedTurnId,
2010
2101
  anchors,
2011
- ownership
2102
+ ownership,
2103
+ searchQuery
2012
2104
  ]);
2013
- if (items.length === 0 && !showThrobber) return /* @__PURE__ */ jsx$1(EmptyState, {});
2105
+ if (items.length === 0 && !showThrobber) {
2106
+ if (searchQuery) return searchPending ? /* @__PURE__ */ jsx$1("box", { style: { flexGrow: 1 } }) : /* @__PURE__ */ jsx$1("text", {
2107
+ fg: COLOR.mute,
2108
+ children: `No messages match “${searchQuery.trim()}”`
2109
+ });
2110
+ return /* @__PURE__ */ jsx$1(EmptyState, {});
2111
+ }
2014
2112
  return /* @__PURE__ */ jsx$1(RowVisibilityContext.Provider, {
2015
2113
  value: rowVisibility.current.store,
2016
2114
  children: /* @__PURE__ */ jsxs("scrollbox", {
2017
2115
  ref: scrollboxRef,
2116
+ id: "transcript-scrollbox",
2018
2117
  focusable: false,
2019
2118
  style: {
2020
2119
  flexGrow: 1,
2021
2120
  paddingLeft: 1,
2022
2121
  paddingRight: 1
2023
2122
  },
2024
- stickyScroll: true,
2025
- stickyStart: "bottom",
2123
+ stickyScroll: !searchQuery,
2124
+ stickyStart: searchQuery ? void 0 : "bottom",
2026
2125
  verticalScrollbarOptions: {
2027
2126
  width: 1,
2028
2127
  trackOptions: {
@@ -2039,7 +2138,9 @@ function Transcript({ events, settings, selectedTurnId = null, busy = false }) {
2039
2138
  selected: isTurnHighlighted(item.event, selectedTurnId, ownership),
2040
2139
  anchorId: anchors.ids[i][0],
2041
2140
  settleCodeEpoch: codeSettle.current.targets.has(item.event) ? codeSettle.current.epoch : 0,
2042
- liveSpawn: live?.[0]
2141
+ liveSpawn: live?.[0],
2142
+ searchQuery,
2143
+ searchTitleMatch: item.event.kind === "tool" && item.event.callId !== void 0 && searchMatches.callIds.has(item.event.callId) || (item.event.kind === "spawn-start" || item.event.kind === "spawn-end") && item.event.childId !== void 0 && searchMatches.childIds.has(item.event.childId)
2043
2144
  }, i);
2044
2145
  }
2045
2146
  return /* @__PURE__ */ jsx$1(SubagentBlock, {
@@ -2049,7 +2150,9 @@ function Transcript({ events, settings, selectedTurnId = null, busy = false }) {
2049
2150
  anchorIds: anchors.ids[i],
2050
2151
  codeSettleTargets: codeSettle.current.targets,
2051
2152
  codeSettleEpoch: codeSettle.current.epoch,
2052
- live: liveByItem.get(i)
2153
+ live: liveByItem.get(i),
2154
+ searchQuery,
2155
+ searchTitleMatches: searchMatches
2053
2156
  }, i);
2054
2157
  }), showThrobber && /* @__PURE__ */ jsx$1("box", {
2055
2158
  style: { marginTop: items.length > 0 ? 1 : 0 },
@@ -2145,8 +2248,8 @@ function collapseCompletedSpawnStarts(events) {
2145
2248
  }
2146
2249
  return events.filter((_, i) => keep[i]);
2147
2250
  }
2148
- function partitionTranscript(events, settings) {
2149
- const visible = collapseCompletedSpawnStarts(events).filter((e) => isVisible(e, settings));
2251
+ function partitionTranscript(events, settings, searchReveal) {
2252
+ const visible = collapseCompletedSpawnStarts(events).filter((event) => isVisible(event, settings) || searchReveal !== void 0 && (event.depth ?? 0) === 0 && searchReveal(event));
2150
2253
  if (visible.length === 0) return [];
2151
2254
  if (settings.hideSubagentOutput) return visible.map((event, i) => ({
2152
2255
  kind: "event",
@@ -2192,7 +2295,7 @@ function partitionTranscript(events, settings) {
2192
2295
  * indented twice. Grandchildren (depth ≥ 2) still indent further, so
2193
2296
  * nested subagents remain visually distinct.
2194
2297
  */
2195
- function SubagentBlock({ events, previous, selectedTurnId = null, anchorIds, codeSettleTargets, codeSettleEpoch = 0, live }) {
2298
+ function SubagentBlock({ events, previous, selectedTurnId = null, anchorIds, codeSettleTargets, codeSettleEpoch = 0, live, searchQuery, searchTitleMatches }) {
2196
2299
  const COLOR = useColors();
2197
2300
  const childIds = useMemo(() => {
2198
2301
  const set = /* @__PURE__ */ new Set();
@@ -2224,7 +2327,9 @@ function SubagentBlock({ events, previous, selectedTurnId = null, anchorIds, cod
2224
2327
  anchorId: anchorIds?.[i],
2225
2328
  hideChildLabel,
2226
2329
  settleCodeEpoch: codeSettleTargets?.has(evt) ? codeSettleEpoch : 0,
2227
- liveSpawn: evt.kind === "spawn-start" ? live?.find((spawn) => spawn.id === evt.childId) : void 0
2330
+ liveSpawn: evt.kind === "spawn-start" ? live?.find((spawn) => spawn.id === evt.childId) : void 0,
2331
+ searchQuery,
2332
+ searchTitleMatch: evt.kind === "tool" && evt.callId !== void 0 && searchTitleMatches?.callIds.has(evt.callId) || (evt.kind === "spawn-start" || evt.kind === "spawn-end") && evt.childId !== void 0 && searchTitleMatches?.childIds.has(evt.childId)
2228
2333
  }, i))
2229
2334
  });
2230
2335
  }
@@ -2233,8 +2338,9 @@ function SubagentBlock({ events, previous, selectedTurnId = null, anchorIds, cod
2233
2338
  * the left lane for the whole run; only the right tail changes:
2234
2339
  * active `2 turns · 14s · ⠋ tool call`, done `4 turns`.
2235
2340
  */
2236
- function SpawnLifecycleMarker({ row, dim, event, hideChildLabel, live }) {
2341
+ function SpawnLifecycleMarker({ row, dim, event, hideChildLabel, live, searchMatch = false }) {
2237
2342
  const COLOR = useColors();
2343
+ const matchColor = resolveChipColor(useSurfaces().chips, "default");
2238
2344
  const frame = useSpinnerFrame();
2239
2345
  const terminal = event.kind === "spawn-end";
2240
2346
  const failed = terminal && (event.text.startsWith("error") || event.text.startsWith("aborted"));
@@ -2259,7 +2365,8 @@ function SpawnLifecycleMarker({ row, dim, event, hideChildLabel, live }) {
2259
2365
  children: terminal ? failed ? "✗ " : "🌳 " : "🌱 "
2260
2366
  }),
2261
2367
  !hideChildLabel && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx$1("span", {
2262
- fg: COLOR.brand,
2368
+ fg: searchMatch ? matchColor.fg : COLOR.brand,
2369
+ bg: searchMatch ? matchColor.bg : void 0,
2263
2370
  children: event.childId ?? "child"
2264
2371
  }), /* @__PURE__ */ jsx$1("span", {
2265
2372
  fg: COLOR.mute,
@@ -2334,7 +2441,7 @@ function rowStyle(paddingLeft) {
2334
2441
  alignSelf: "stretch"
2335
2442
  };
2336
2443
  }
2337
- function EventLineImpl({ event, previous, depthOffset = 0, hideChildLabel = false, selected = false, settleCodeEpoch = 0, liveSpawn }) {
2444
+ function EventLineImpl({ event, previous, depthOffset = 0, hideChildLabel = false, selected = false, settleCodeEpoch = 0, liveSpawn, searchQuery, searchTitleMatch = false }) {
2338
2445
  const COLOR = useColors();
2339
2446
  const { settings } = useSettings();
2340
2447
  const cleanText = stripTerminalControlChars(event.text);
@@ -2347,7 +2454,8 @@ function EventLineImpl({ event, previous, depthOffset = 0, hideChildLabel = fals
2347
2454
  case "user-prompt": return /* @__PURE__ */ jsx$1(UserPromptBlock, {
2348
2455
  text: safeText,
2349
2456
  refs: event.refs,
2350
- attachments: event.attachments
2457
+ attachments: event.attachments,
2458
+ searchQuery
2351
2459
  });
2352
2460
  case "info": return /* @__PURE__ */ jsx$1("box", {
2353
2461
  style: row,
@@ -2405,7 +2513,8 @@ function EventLineImpl({ event, previous, depthOffset = 0, hideChildLabel = fals
2405
2513
  children: [/* @__PURE__ */ jsx$1(ToolCallBlock, {
2406
2514
  event,
2407
2515
  display: settings.toolCallDisplay === "full" ? "full" : "formatted",
2408
- dim: child
2516
+ dim: child,
2517
+ searchMatch: searchTitleMatch
2409
2518
  }), event.tool === "todowrite" && /* @__PURE__ */ jsx$1(TodoInProgressList, {
2410
2519
  input: event.input,
2411
2520
  dim: child
@@ -2417,7 +2526,8 @@ function EventLineImpl({ event, previous, depthOffset = 0, hideChildLabel = fals
2417
2526
  renderDefault: () => /* @__PURE__ */ jsx$1(ToolResultBlock, {
2418
2527
  text: event.text,
2419
2528
  indent: row.paddingLeft,
2420
- maxLines: isUserBashEvent(event) ? settings.showFullShellOutput ? Number.MAX_SAFE_INTEGER : BASH_RESULT_MAX_LINES : void 0
2529
+ maxLines: isUserBashEvent(event) ? settings.showFullShellOutput ? Number.MAX_SAFE_INTEGER : BASH_RESULT_MAX_LINES : void 0,
2530
+ searchQuery
2421
2531
  })
2422
2532
  });
2423
2533
  case "custom": return /* @__PURE__ */ jsx$1("box", {
@@ -2462,13 +2572,15 @@ function EventLineImpl({ event, previous, depthOffset = 0, hideChildLabel = fals
2462
2572
  dim: COLOR.dim,
2463
2573
  event,
2464
2574
  hideChildLabel,
2465
- live: liveSpawn
2575
+ live: liveSpawn,
2576
+ searchMatch: searchTitleMatch
2466
2577
  });
2467
2578
  case "spawn-end": return /* @__PURE__ */ jsx$1(SpawnLifecycleMarker, {
2468
2579
  row,
2469
2580
  dim: COLOR.dim,
2470
2581
  event,
2471
- hideChildLabel
2582
+ hideChildLabel,
2583
+ searchMatch: searchTitleMatch
2472
2584
  });
2473
2585
  case "compact-summary": return /* @__PURE__ */ jsx$1(CompactSummaryBlock, {
2474
2586
  event,
@@ -2636,9 +2748,10 @@ function CompactSummaryBlock({ event, indent }) {
2636
2748
  */
2637
2749
  /** Prompt chevron rendered ahead of every user-prompt block. */
2638
2750
  const USER_PROMPT_PREFIX = "❯ ";
2639
- function UserPromptBlock({ text, refs, attachments }) {
2751
+ function UserPromptBlock({ text, refs, attachments, searchQuery }) {
2640
2752
  const COLOR = useColors();
2641
2753
  const SURFACE = useSurfaces();
2754
+ const matchColor = resolveChipColor(SURFACE.chips, "default");
2642
2755
  const boxStyle = {
2643
2756
  border: true,
2644
2757
  borderColor: COLOR.borderActive,
@@ -2671,20 +2784,30 @@ function UserPromptBlock({ text, refs, attachments }) {
2671
2784
  })] }, `att-${idx}`);
2672
2785
  })
2673
2786
  }) : null;
2674
- if (!refs || refs.length === 0) return /* @__PURE__ */ jsxs("box", {
2675
- style: boxStyle,
2676
- children: [
2677
- text.length > 0 && /* @__PURE__ */ jsxs("text", { children: [/* @__PURE__ */ jsx$1("span", {
2678
- fg: COLOR.brand,
2679
- children: USER_PROMPT_PREFIX
2680
- }), text] }),
2681
- !text.length && attachmentChips && /* @__PURE__ */ jsx$1("text", {
2682
- fg: COLOR.brand,
2683
- children: USER_PROMPT_PREFIX
2684
- }),
2685
- attachmentChips
2686
- ]
2687
- });
2787
+ if (!refs || refs.length === 0) {
2788
+ const ranges = searchQuery ? searchHighlightRanges(text, searchQuery) : [];
2789
+ return /* @__PURE__ */ jsxs("box", {
2790
+ style: boxStyle,
2791
+ children: [
2792
+ text.length > 0 && /* @__PURE__ */ jsxs("text", {
2793
+ wrapMode: "word",
2794
+ children: [/* @__PURE__ */ jsx$1("span", {
2795
+ fg: COLOR.brand,
2796
+ children: USER_PROMPT_PREFIX
2797
+ }), highlightSegments(text, ranges).map((segment, i) => /* @__PURE__ */ jsx$1("span", {
2798
+ fg: segment.highlighted ? matchColor.fg : void 0,
2799
+ bg: segment.highlighted ? matchColor.bg : void 0,
2800
+ children: segment.text
2801
+ }, i))]
2802
+ }),
2803
+ !text.length && attachmentChips && /* @__PURE__ */ jsx$1("text", {
2804
+ fg: COLOR.brand,
2805
+ children: USER_PROMPT_PREFIX
2806
+ }),
2807
+ attachmentChips
2808
+ ]
2809
+ });
2810
+ }
2688
2811
  const segments = splitPromptSegments(text, refs);
2689
2812
  return /* @__PURE__ */ jsxs("box", {
2690
2813
  style: boxStyle,
@@ -2697,7 +2820,14 @@ function UserPromptBlock({ text, refs, attachments }) {
2697
2820
  fg: COLOR.brand,
2698
2821
  children: USER_PROMPT_PREFIX
2699
2822
  }), segments.map((seg, i) => {
2700
- if (seg.kind === "plain") return /* @__PURE__ */ jsx$1("text", { children: seg.text }, i);
2823
+ if (seg.kind === "plain") {
2824
+ const ranges = searchQuery ? searchHighlightRanges(seg.text, searchQuery) : [];
2825
+ return /* @__PURE__ */ jsx$1("text", { children: highlightSegments(seg.text, ranges).map((part, j) => /* @__PURE__ */ jsx$1("span", {
2826
+ fg: part.highlighted ? matchColor.fg : void 0,
2827
+ bg: part.highlighted ? matchColor.bg : void 0,
2828
+ children: part.text
2829
+ }, j)) }, i);
2830
+ }
2701
2831
  const chip = resolveChipColor(SURFACE.chips, seg.providerId);
2702
2832
  return /* @__PURE__ */ jsx$1("text", {
2703
2833
  fg: chip.fg,
@@ -3132,33 +3262,64 @@ function CommandDetailBlock({ label, text, indent, diff }) {
3132
3262
  })]
3133
3263
  });
3134
3264
  }
3135
- function ToolResultBlock({ text, indent, diff, maxLines = TOOL_RESULT_MAX_LINES }) {
3265
+ function ToolResultBlock({ text, indent, diff, maxLines = TOOL_RESULT_MAX_LINES, searchQuery }) {
3136
3266
  const COLOR = useColors();
3137
- const rawLines = stripAnsiSequences(text).split("\n");
3138
- let end = rawLines.length;
3139
- while (end > 0 && rawLines[end - 1].trim() === "") end--;
3140
- if (end === 0) return null;
3141
- const lines = rawLines.slice(0, end);
3142
- const visible = lines.slice(0, maxLines);
3143
- const omitted = Math.max(0, lines.length - maxLines);
3267
+ const matchColor = resolveChipColor(useSurfaces().chips, "default");
3268
+ const { lines, start } = useMemo(() => {
3269
+ const rawLines = stripAnsiSequences(text).split("\n");
3270
+ let end = rawLines.length;
3271
+ while (end > 0 && rawLines[end - 1].trim() === "") end--;
3272
+ const trimmed = rawLines.slice(0, end);
3273
+ let windowStart = 0;
3274
+ if (searchQuery && trimmed.length > maxLines) {
3275
+ const matchIdx = trimmed.findIndex((line) => searchHighlightRanges(line, searchQuery).length > 0);
3276
+ if (matchIdx >= maxLines) windowStart = Math.min(Math.max(0, matchIdx - Math.floor(maxLines / 2)), trimmed.length - maxLines);
3277
+ }
3278
+ return {
3279
+ lines: trimmed,
3280
+ start: windowStart
3281
+ };
3282
+ }, [
3283
+ text,
3284
+ searchQuery,
3285
+ maxLines
3286
+ ]);
3287
+ if (lines.length === 0) return null;
3288
+ const visible = lines.slice(start, start + maxLines);
3289
+ const omittedBefore = start;
3290
+ const omitted = Math.max(0, lines.length - start - maxLines);
3144
3291
  return /* @__PURE__ */ jsxs("box", {
3145
3292
  style: {
3146
3293
  paddingLeft: indent,
3147
3294
  flexDirection: "column"
3148
3295
  },
3149
- children: [visible.map((line, i) => /* @__PURE__ */ jsxs("text", {
3150
- fg: COLOR.mute,
3151
- children: [/* @__PURE__ */ jsx$1("span", {
3152
- fg: COLOR.borderActive,
3153
- children: "┃ "
3154
- }), diff && line ? /* @__PURE__ */ jsx$1(DiffStatLine, { line }) : line || " "]
3155
- }, i)), omitted > 0 && /* @__PURE__ */ jsxs("text", {
3156
- fg: COLOR.mute,
3157
- children: [/* @__PURE__ */ jsx$1("span", {
3158
- fg: COLOR.borderActive,
3159
- children: " "
3160
- }), `… ${omitted} more line${omitted === 1 ? "" : "s"}`]
3161
- })]
3296
+ children: [
3297
+ omittedBefore > 0 && /* @__PURE__ */ jsxs("text", {
3298
+ fg: COLOR.mute,
3299
+ children: [/* @__PURE__ */ jsx$1("span", {
3300
+ fg: COLOR.borderActive,
3301
+ children: " "
3302
+ }), `… ${omittedBefore} earlier line${omittedBefore === 1 ? "" : "s"}`]
3303
+ }),
3304
+ visible.map((line, i) => /* @__PURE__ */ jsxs("text", {
3305
+ fg: COLOR.mute,
3306
+ children: [/* @__PURE__ */ jsx$1("span", {
3307
+ fg: COLOR.borderActive,
3308
+ children: "┃ "
3309
+ }), diff && line ? /* @__PURE__ */ jsx$1(DiffStatLine, { line }) : highlightSegments(line || " ", searchQuery ? searchHighlightRanges(line, searchQuery) : []).map((segment, j) => /* @__PURE__ */ jsx$1("span", {
3310
+ fg: segment.highlighted ? matchColor.fg : COLOR.mute,
3311
+ bg: segment.highlighted ? matchColor.bg : void 0,
3312
+ children: segment.text
3313
+ }, j))]
3314
+ }, i)),
3315
+ omitted > 0 && /* @__PURE__ */ jsxs("text", {
3316
+ fg: COLOR.mute,
3317
+ children: [/* @__PURE__ */ jsx$1("span", {
3318
+ fg: COLOR.borderActive,
3319
+ children: "┃ "
3320
+ }), `… ${omitted} more line${omitted === 1 ? "" : "s"}`]
3321
+ })
3322
+ ]
3162
3323
  });
3163
3324
  }
3164
3325
  function EditDiffBlock({ payload, dim }) {
@@ -3515,8 +3676,9 @@ function HunkBlock({ index, hunk, outcome, tool, path, filetype, mdStyle, COLOR,
3515
3676
  });
3516
3677
  }
3517
3678
  const FULL_VIEW_MAX_INPUT_BYTES = 32 * 1024;
3518
- function ToolCallBlock({ event, display, dim }) {
3679
+ function ToolCallBlock({ event, display, dim, searchMatch = false }) {
3519
3680
  const COLOR = useColors();
3681
+ const matchColor = resolveChipColor(useSurfaces().chips, "default");
3520
3682
  const mdStyle = useMdStyle();
3521
3683
  const name = event.tool ?? "";
3522
3684
  const verb = displayNameFor(name, event.input);
@@ -3544,7 +3706,8 @@ function ToolCallBlock({ event, display, dim }) {
3544
3706
  children: "↳ "
3545
3707
  }),
3546
3708
  /* @__PURE__ */ jsx$1("span", {
3547
- fg: dim ? COLOR.dim : COLOR.model,
3709
+ fg: searchMatch ? matchColor.fg : dim ? COLOR.dim : COLOR.model,
3710
+ bg: searchMatch ? matchColor.bg : void 0,
3548
3711
  children: verb
3549
3712
  }),
3550
3713
  /* @__PURE__ */ jsx$1("span", {
@@ -3576,7 +3739,8 @@ function ToolCallBlock({ event, display, dim }) {
3576
3739
  children: "↳ "
3577
3740
  }),
3578
3741
  /* @__PURE__ */ jsx$1("span", {
3579
- fg: dim ? COLOR.dim : COLOR.model,
3742
+ fg: searchMatch ? matchColor.fg : dim ? COLOR.dim : COLOR.model,
3743
+ bg: searchMatch ? matchColor.bg : void 0,
3580
3744
  children: verb
3581
3745
  }),
3582
3746
  line?.target && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx$1("span", {
@@ -4946,6 +5110,255 @@ async function resolveExtensionModalNode(node) {
4946
5110
  return loaded;
4947
5111
  }
4948
5112
  //#endregion
5113
+ //#region src/tui/history-search-worker-client.ts
5114
+ let nextClientId = 1;
5115
+ /** Mirror of the TUI's ZIDANE_DEBUG-gated logger — silent by default. */
5116
+ const debugFallback = process.env.ZIDANE_DEBUG ? (op, error) => console.error(`[zidane/tui] history search ${op} fell back to the main thread:`, error) : () => {};
5117
+ function workerUrl() {
5118
+ if (typeof Bun !== "undefined" && Bun.isStandaloneExecutable) return new URL("./history-search-worker.ts", import.meta.url);
5119
+ return new URL(import.meta.url.endsWith(".ts") ? "./history-search-worker.ts" : "./history-search-worker.js", import.meta.url);
5120
+ }
5121
+ var HistorySearchWorkerClient = class {
5122
+ createWorker;
5123
+ worker = null;
5124
+ constructor(createWorker = () => new Worker(workerUrl(), {
5125
+ type: "module",
5126
+ smol: true
5127
+ })) {
5128
+ this.createWorker = createWorker;
5129
+ }
5130
+ indexes = /* @__PURE__ */ new WeakMap();
5131
+ registrations = /* @__PURE__ */ new Map();
5132
+ rebuilds = /* @__PURE__ */ new Map();
5133
+ pending = /* @__PURE__ */ new Map();
5134
+ nextRequestId = 1;
5135
+ unavailable = false;
5136
+ prewarm() {
5137
+ if (this.unavailable) return;
5138
+ try {
5139
+ this.ensureWorker();
5140
+ } catch {}
5141
+ }
5142
+ async build(turns, signal) {
5143
+ signal?.throwIfAborted();
5144
+ const indexId = `search-index-${nextClientId++}`;
5145
+ if (this.unavailable) return {
5146
+ indexId,
5147
+ length: turns.length,
5148
+ turns
5149
+ };
5150
+ try {
5151
+ return {
5152
+ ...await this.request({
5153
+ type: "build",
5154
+ indexId,
5155
+ turns
5156
+ }, signal),
5157
+ turns
5158
+ };
5159
+ } catch (error) {
5160
+ if (signal?.aborted) throw signal.reason;
5161
+ debugFallback("build", error);
5162
+ return {
5163
+ indexId,
5164
+ length: turns.length,
5165
+ turns
5166
+ };
5167
+ }
5168
+ }
5169
+ async search(index, query, limit, signal) {
5170
+ if (this.unavailable) return searchSessionIndexAsync(await this.fallbackIndex(index, signal), query, {
5171
+ limit,
5172
+ signal
5173
+ });
5174
+ try {
5175
+ const indexId = "indexId" in index ? index.indexId : await this.register(index);
5176
+ return await this.request({
5177
+ type: "search",
5178
+ indexId,
5179
+ query,
5180
+ limit
5181
+ }, signal);
5182
+ } catch (error) {
5183
+ if (signal?.aborted) throw signal.reason;
5184
+ if ("indexId" in index && error instanceof Error && error.message.startsWith("Unknown search index:")) {
5185
+ await this.rebuild(index);
5186
+ return this.request({
5187
+ type: "search",
5188
+ indexId: index.indexId,
5189
+ query,
5190
+ limit
5191
+ }, signal);
5192
+ }
5193
+ debugFallback("search", error);
5194
+ return searchSessionIndexAsync(await this.fallbackIndex(index, signal), query, {
5195
+ limit,
5196
+ signal
5197
+ });
5198
+ }
5199
+ }
5200
+ async history(index, query, maxExcerpts, signal) {
5201
+ if (this.unavailable) return searchSessionHistoryIndexAsync(await this.fallbackIndex(index, signal), query, maxExcerpts, void 0, signal);
5202
+ try {
5203
+ const indexId = "indexId" in index ? index.indexId : await this.register(index);
5204
+ return await this.request({
5205
+ type: "history",
5206
+ indexId,
5207
+ query,
5208
+ maxExcerpts
5209
+ }, signal);
5210
+ } catch (error) {
5211
+ if (signal?.aborted) throw signal.reason;
5212
+ if ("indexId" in index && error instanceof Error && error.message.startsWith("Unknown search index:")) {
5213
+ await this.rebuild(index);
5214
+ return this.request({
5215
+ type: "history",
5216
+ indexId: index.indexId,
5217
+ query,
5218
+ maxExcerpts
5219
+ }, signal);
5220
+ }
5221
+ debugFallback("history", error);
5222
+ return searchSessionHistoryIndexAsync(await this.fallbackIndex(index, signal), query, maxExcerpts, void 0, signal);
5223
+ }
5224
+ }
5225
+ terminate() {
5226
+ this.worker?.terminate();
5227
+ this.worker = null;
5228
+ const error = /* @__PURE__ */ new Error("History search worker terminated");
5229
+ for (const pending of this.pending.values()) pending.reject(error);
5230
+ this.pending.clear();
5231
+ this.registrations.clear();
5232
+ this.rebuilds.clear();
5233
+ }
5234
+ /** Re-register an evicted worker index; concurrent callers share one build. */
5235
+ rebuild(index) {
5236
+ const inFlight = this.rebuilds.get(index.indexId);
5237
+ if (inFlight) return inFlight;
5238
+ const rebuild = this.request({
5239
+ type: "build",
5240
+ indexId: index.indexId,
5241
+ turns: index.turns
5242
+ }).then(() => {}).finally(() => this.rebuilds.delete(index.indexId));
5243
+ this.rebuilds.set(index.indexId, rebuild);
5244
+ return rebuild;
5245
+ }
5246
+ fallbackIndex(index, signal) {
5247
+ if (!("indexId" in index)) return Promise.resolve(index);
5248
+ if (!index.fallback) index.fallback = buildSessionSearchIndexAsync(index.turns).catch((error) => {
5249
+ index.fallback = void 0;
5250
+ throw error;
5251
+ });
5252
+ signal?.throwIfAborted();
5253
+ return index.fallback;
5254
+ }
5255
+ ensureWorker() {
5256
+ if (this.worker) return this.worker;
5257
+ let worker;
5258
+ try {
5259
+ worker = this.createWorker();
5260
+ } catch (error) {
5261
+ this.disable(error);
5262
+ throw error;
5263
+ }
5264
+ worker.onmessage = (event) => this.onMessage(event.data);
5265
+ worker.onerror = (event) => this.disable(event.error ?? new Error(event.message));
5266
+ this.worker = worker;
5267
+ return worker;
5268
+ }
5269
+ register(index) {
5270
+ const known = this.indexes.get(index);
5271
+ if (known) {
5272
+ const pending = this.registrations.get(known);
5273
+ return pending ? pending.then(() => known) : Promise.resolve(known);
5274
+ }
5275
+ const indexId = `search-index-${nextClientId++}`;
5276
+ this.indexes.set(index, indexId);
5277
+ const registration = new Promise((resolve, reject) => {
5278
+ const worker = this.ensureWorker();
5279
+ let onRegistered;
5280
+ const onError = () => {
5281
+ worker.removeEventListener("message", onRegistered);
5282
+ this.registrations.delete(indexId);
5283
+ reject(/* @__PURE__ */ new Error("History search worker registration failed"));
5284
+ };
5285
+ onRegistered = (event) => {
5286
+ if (event.data.type !== "registered" || event.data.indexId !== indexId) return;
5287
+ worker.removeEventListener("message", onRegistered);
5288
+ worker.removeEventListener("error", onError);
5289
+ this.registrations.delete(indexId);
5290
+ resolve();
5291
+ };
5292
+ worker.addEventListener("message", onRegistered);
5293
+ worker.addEventListener("error", onError, { once: true });
5294
+ try {
5295
+ worker.postMessage({
5296
+ type: "register",
5297
+ indexId,
5298
+ index
5299
+ });
5300
+ } catch (error) {
5301
+ worker.removeEventListener("message", onRegistered);
5302
+ worker.removeEventListener("error", onError);
5303
+ reject(error instanceof Error ? error : new Error(String(error)));
5304
+ }
5305
+ });
5306
+ this.registrations.set(indexId, registration);
5307
+ return registration.then(() => indexId);
5308
+ }
5309
+ request(payload, signal) {
5310
+ signal?.throwIfAborted();
5311
+ const requestId = this.nextRequestId++;
5312
+ const worker = this.ensureWorker();
5313
+ return new Promise((resolve, reject) => {
5314
+ const abort = () => {
5315
+ worker.postMessage({
5316
+ type: "cancel",
5317
+ requestId
5318
+ });
5319
+ this.pending.delete(requestId);
5320
+ reject(signal?.reason ?? new DOMException("Aborted", "AbortError"));
5321
+ };
5322
+ signal?.addEventListener("abort", abort, { once: true });
5323
+ this.pending.set(requestId, {
5324
+ resolve: (value) => {
5325
+ signal?.removeEventListener("abort", abort);
5326
+ resolve(value);
5327
+ },
5328
+ reject: (error) => {
5329
+ signal?.removeEventListener("abort", abort);
5330
+ reject(error);
5331
+ }
5332
+ });
5333
+ try {
5334
+ worker.postMessage({
5335
+ ...payload,
5336
+ requestId
5337
+ });
5338
+ } catch (error) {
5339
+ this.pending.delete(requestId);
5340
+ signal?.removeEventListener("abort", abort);
5341
+ reject(error instanceof Error ? error : new Error(String(error)));
5342
+ }
5343
+ });
5344
+ }
5345
+ onMessage(response) {
5346
+ if (response.requestId === void 0) return;
5347
+ const pending = this.pending.get(response.requestId);
5348
+ if (!pending) return;
5349
+ this.pending.delete(response.requestId);
5350
+ if (response.type === "error") pending.reject(new Error(response.message ?? "History search worker failed"));
5351
+ else pending.resolve(response.result);
5352
+ }
5353
+ disable(error) {
5354
+ this.unavailable = true;
5355
+ const reason = error instanceof Error ? error : new Error(String(error));
5356
+ for (const pending of this.pending.values()) pending.reject(reason);
5357
+ this.pending.clear();
5358
+ this.terminate();
5359
+ }
5360
+ };
5361
+ //#endregion
4949
5362
  //#region src/tui/keybindings-modal.tsx
4950
5363
  function KeybindingsModal({ bindings, extensionRows, filePath, onEditFile, onClose }) {
4951
5364
  const SURFACE = useSurfaces();
@@ -5413,6 +5826,38 @@ function OptionRow({ option, isEnabled, isFocused, highlightBg }) {
5413
5826
  */
5414
5827
  /** Visible rows in the windowed list. Keeps the modal a stable size on long catalogs. */
5415
5828
  const VISIBLE_ROWS = 10;
5829
+ /**
5830
+ * Promote selected entries in SELECTION order, then append every unselected
5831
+ * row in its original catalog order. Pure/non-mutating and collision-safe:
5832
+ * duplicate/missing selection keys are ignored. Call only for an empty query
5833
+ * — search results must retain `filterModelCatalog`'s relevance order.
5834
+ */
5835
+ function selectedModelsFirst(entries, selectedKeys, keyOf) {
5836
+ if (selectedKeys.length === 0) return [...entries];
5837
+ const byKey = new Map(entries.map((entry) => [keyOf(entry), entry]));
5838
+ const selected = [];
5839
+ const promoted = /* @__PURE__ */ new Set();
5840
+ for (const key of selectedKeys) {
5841
+ const entry = byKey.get(key);
5842
+ if (!entry || promoted.has(key)) continue;
5843
+ selected.push(entry);
5844
+ promoted.add(key);
5845
+ }
5846
+ return [...selected, ...entries.filter((entry) => !promoted.has(keyOf(entry)))];
5847
+ }
5848
+ function providerModelKey(providerKey, modelId) {
5849
+ return `${providerKey}\u0000${modelId}`;
5850
+ }
5851
+ /** Move one selected entry by one priority slot; boundary/missing = identity. */
5852
+ function reorderSelectedModel(selected, key, delta, keyOf) {
5853
+ const from = selected.findIndex((entry) => keyOf(entry) === key);
5854
+ const to = from + delta;
5855
+ if (from < 0 || to < 0 || to >= selected.length) return selected;
5856
+ const next = [...selected];
5857
+ const [entry] = next.splice(from, 1);
5858
+ next.splice(to, 0, entry);
5859
+ return next;
5860
+ }
5416
5861
  function ModelPickerModal({ providers, modelsFor, current, onPick, chain, onApplyChain }) {
5417
5862
  const COLOR = useColors();
5418
5863
  const SURFACE = useSurfaces();
@@ -5430,13 +5875,6 @@ function ModelPickerModal({ providers, modelsFor, current, onPick, chain, onAppl
5430
5875
  modelsFor,
5431
5876
  current
5432
5877
  ]);
5433
- const filtered = useMemo(() => filterModelCatalog(catalog, query), [catalog, query]);
5434
- const [selectedIdx, setSelectedIdx] = useState(() => Math.max(0, indexOfEntry(catalog, current)));
5435
- const handleQueryChange = useCallback((next) => {
5436
- setQuery(next);
5437
- setSelectedIdx(0);
5438
- }, []);
5439
- const safeIndex = filtered.length === 0 ? 0 : Math.min(selectedIdx, filtered.length - 1);
5440
5878
  const chainMode = onApplyChain !== void 0;
5441
5879
  const [initialChain] = useState(() => chain && chain.length > 0 ? [...chain] : [{
5442
5880
  provider: current.providerKey,
@@ -5444,6 +5882,21 @@ function ModelPickerModal({ providers, modelsFor, current, onPick, chain, onAppl
5444
5882
  }]);
5445
5883
  const [chainState, setChainState] = useState(initialChain);
5446
5884
  const modalApi = useModalState();
5885
+ const filtered = useMemo(() => {
5886
+ const matches = filterModelCatalog(catalog, query);
5887
+ if (query.trim()) return matches;
5888
+ return selectedModelsFirst(matches, chainState.map((pick) => providerModelKey(pick.provider, pick.model)), (row) => providerModelKey(row.providerKey, row.model.id));
5889
+ }, [
5890
+ catalog,
5891
+ query,
5892
+ chainState
5893
+ ]);
5894
+ const [selectedIdx, setSelectedIdx] = useState(0);
5895
+ const handleQueryChange = useCallback((next) => {
5896
+ setQuery(next);
5897
+ setSelectedIdx(0);
5898
+ }, []);
5899
+ const safeIndex = filtered.length === 0 ? 0 : Math.min(selectedIdx, filtered.length - 1);
5447
5900
  const commit = () => {
5448
5901
  const row = filtered[safeIndex];
5449
5902
  if (!row) return;
@@ -5479,6 +5932,26 @@ function ModelPickerModal({ providers, modelsFor, current, onPick, chain, onAppl
5479
5932
  };
5480
5933
  }, [filtered, safeIndex]);
5481
5934
  useKeyboard((key) => {
5935
+ if (key.ctrl && key.shift && (key.name === "up" || key.name === "down")) {
5936
+ if (chainMode) {
5937
+ const row = filtered[safeIndex];
5938
+ if (row) {
5939
+ const keyOf = (entry) => providerModelKey(entry.provider, entry.model);
5940
+ const selectedKey = providerModelKey(row.providerKey, row.model.id);
5941
+ const delta = key.name === "up" ? -1 : 1;
5942
+ const reordered = reorderSelectedModel(chainState, selectedKey, delta, keyOf);
5943
+ if (reordered !== chainState) {
5944
+ setChainState([...reordered]);
5945
+ if (!query.trim()) {
5946
+ const served = new Set(catalog.map((entry) => providerModelKey(entry.providerKey, entry.model.id)));
5947
+ const next = reordered.filter((entry) => served.has(keyOf(entry))).findIndex((entry) => keyOf(entry) === selectedKey);
5948
+ if (next >= 0) setSelectedIdx(next);
5949
+ }
5950
+ }
5951
+ }
5952
+ }
5953
+ return;
5954
+ }
5482
5955
  if (key.name === "up") {
5483
5956
  setSelectedIdx((i) => {
5484
5957
  if (filtered.length === 0) return i;
@@ -5554,6 +6027,10 @@ function ModelPickerModal({ providers, modelsFor, current, onPick, chain, onAppl
5554
6027
  children: "↵"
5555
6028
  }),
5556
6029
  chainMode ? " add/remove · " : " select · ",
6030
+ chainMode && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx$1("span", {
6031
+ fg: COLOR.warn,
6032
+ children: "ctrl+shift+↑↓"
6033
+ }), " reorder · "] }),
5557
6034
  /* @__PURE__ */ jsx$1("span", {
5558
6035
  fg: COLOR.warn,
5559
6036
  children: "esc"
@@ -5606,23 +6083,41 @@ function ModelSelectList({ catalog, multi = false, initial, onApply, onCancel, v
5606
6083
  return true;
5607
6084
  });
5608
6085
  }, [catalog]);
5609
- const filtered = useMemo(() => filterModelCatalog(deduped, query), [deduped, query]);
5610
- const [selectedIdx, setSelectedIdx] = useState(() => {
5611
- const first = initial[0];
5612
- const idx = first ? deduped.findIndex((e) => e.model.id === first) : -1;
5613
- return Math.max(0, idx);
6086
+ const [initialPicks] = useState(() => {
6087
+ const known = new Set(deduped.map((e) => e.model.id));
6088
+ return initial.filter((id) => known.has(id));
5614
6089
  });
6090
+ const [picks, setPicks] = useState(initialPicks);
6091
+ const filtered = useMemo(() => {
6092
+ const matches = filterModelCatalog(deduped, query);
6093
+ return query.trim() ? matches : selectedModelsFirst(matches, picks, (row) => row.model.id);
6094
+ }, [
6095
+ deduped,
6096
+ query,
6097
+ picks
6098
+ ]);
6099
+ const [selectedIdx, setSelectedIdx] = useState(0);
5615
6100
  const handleQueryChange = useCallback((next) => {
5616
6101
  setQuery(next);
5617
6102
  setSelectedIdx(0);
5618
6103
  }, []);
5619
6104
  const safeIndex = filtered.length === 0 ? 0 : Math.min(selectedIdx, filtered.length - 1);
5620
- const [initialPicks] = useState(() => {
5621
- const known = new Set(deduped.map((e) => e.model.id));
5622
- return initial.filter((id) => known.has(id));
5623
- });
5624
- const [picks, setPicks] = useState(initialPicks);
5625
6105
  useKeyboard((key) => {
6106
+ if (key.ctrl && key.shift && (key.name === "up" || key.name === "down")) {
6107
+ if (multi) {
6108
+ const row = filtered[safeIndex];
6109
+ if (row) {
6110
+ const delta = key.name === "up" ? -1 : 1;
6111
+ const from = picks.indexOf(row.model.id);
6112
+ const reordered = reorderSelectedModel(picks, row.model.id, delta, (id) => id);
6113
+ if (reordered !== picks) {
6114
+ setPicks(reordered);
6115
+ if (!query.trim()) setSelectedIdx(from + delta);
6116
+ }
6117
+ }
6118
+ }
6119
+ return;
6120
+ }
5626
6121
  if (key.name === "up" || key.name === "down") {
5627
6122
  setSelectedIdx((i) => {
5628
6123
  if (filtered.length === 0) return i;
@@ -5724,6 +6219,10 @@ function ModelSelectList({ catalog, multi = false, initial, onApply, onCancel, v
5724
6219
  children: "↵"
5725
6220
  }),
5726
6221
  multi ? " add/remove · " : " select · ",
6222
+ multi && /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx$1("span", {
6223
+ fg: COLOR.warn,
6224
+ children: "ctrl+shift+↑↓"
6225
+ }), " reorder · "] }),
5727
6226
  /* @__PURE__ */ jsx$1("span", {
5728
6227
  fg: COLOR.warn,
5729
6228
  children: "esc"
@@ -9294,21 +9793,27 @@ function isSessionRowId(rowId) {
9294
9793
  }
9295
9794
  /** Page-up / page-down jump size — half a typical visible window. */
9296
9795
  const PAGE_JUMP = 6;
9297
- function SessionsScreen({ sessions, currentId, focusedSessionId, onPick, onCreate, onFocusChange, showAllProjects = false, onToggleScope, currentProjectRoot }) {
9796
+ function SessionsScreen({ sessions, currentId, focusedSessionId, onPick, onCreate, onFocusChange, contentMatches = /* @__PURE__ */ new Map(), contentSearching = false, contentSearchProgress = {
9797
+ done: 0,
9798
+ total: 0
9799
+ }, onSearchQueryChange, showAllProjects = false, onToggleScope, currentProjectRoot }) {
9298
9800
  const focused = useModalAwareFocus();
9299
9801
  const COLOR = useColors();
9300
9802
  const inputRef = useRef(null);
9301
- const [query, setQuery] = useState("");
9302
- const filteredSessions = useMemo(() => {
9303
- const trimmed = query.trim().toLowerCase();
9304
- if (!trimmed) return sessions;
9305
- const terms = trimmed.split(/\s+/);
9306
- return sessions.filter((meta) => {
9307
- const projectBasename = meta.projectRoot?.split("/").pop() ?? "";
9308
- const corpus = `${meta.title} ${projectBasename} ${meta.projectRoot ?? ""}`.toLowerCase();
9309
- return terms.every((t) => corpus.includes(t));
9310
- });
9311
- }, [sessions, query]);
9803
+ const scrollboxRef = useRef(null);
9804
+ const [query, setQueryState] = useState("");
9805
+ const searchChangeRef = useRef(onSearchQueryChange);
9806
+ searchChangeRef.current = onSearchQueryChange;
9807
+ useEffect(() => () => searchChangeRef.current?.(""), []);
9808
+ const setQuery = useCallback((next) => {
9809
+ setQueryState(next);
9810
+ onSearchQueryChange?.(next);
9811
+ }, [onSearchQueryChange]);
9812
+ const filteredSessions = useMemo(() => filterSessionSearchCatalog(sessions, query, contentMatches), [
9813
+ sessions,
9814
+ query,
9815
+ contentMatches
9816
+ ]);
9312
9817
  const rows = useMemo(() => [{
9313
9818
  kind: "new",
9314
9819
  rowId: NEW_SESSION_ROW_ID,
@@ -9316,8 +9821,9 @@ function SessionsScreen({ sessions, currentId, focusedSessionId, onPick, onCreat
9316
9821
  }, ...filteredSessions.map((meta) => ({
9317
9822
  kind: "session",
9318
9823
  rowId: meta.id,
9319
- meta
9320
- }))], [filteredSessions]);
9824
+ meta,
9825
+ ...query.trim() && contentMatches.get(meta.id) ? { contentMatch: contentMatches.get(meta.id) } : {}
9826
+ }))], [filteredSessions, contentMatches]);
9321
9827
  useEffect(() => {
9322
9828
  if (focused) inputRef.current?.focus();
9323
9829
  }, [focused]);
@@ -9329,6 +9835,33 @@ function SessionsScreen({ sessions, currentId, focusedSessionId, onPick, onCreat
9329
9835
  }
9330
9836
  return rows.length > 1 ? 1 : 0;
9331
9837
  }, [rows, focusedSessionId]);
9838
+ const lastScrolledCursorRef = useRef(-1);
9839
+ useEffect(() => {
9840
+ if (!focused) return;
9841
+ if (lastScrolledCursorRef.current === cursorIndex) return;
9842
+ const scrollbox = scrollboxRef.current;
9843
+ if (!scrollbox) return;
9844
+ let handle = 0;
9845
+ let attempts = 0;
9846
+ const align = () => {
9847
+ const child = scrollbox.content.findDescendantById(`session-row-${cursorIndex}`);
9848
+ if (child) {
9849
+ const childTop = child.y - scrollbox.content.y;
9850
+ const childBottom = childTop + child.height;
9851
+ if (childTop < scrollbox.scrollTop) scrollbox.scrollTop = childTop;
9852
+ else if (childBottom > scrollbox.scrollTop + scrollbox.viewport.height) scrollbox.scrollTop = childBottom - scrollbox.viewport.height;
9853
+ lastScrolledCursorRef.current = cursorIndex;
9854
+ return;
9855
+ }
9856
+ if (attempts++ < 5) handle = requestAnimationFrame(align);
9857
+ };
9858
+ handle = requestAnimationFrame(align);
9859
+ return () => cancelAnimationFrame(handle);
9860
+ }, [
9861
+ cursorIndex,
9862
+ focused,
9863
+ rows
9864
+ ]);
9332
9865
  useEffect(() => {
9333
9866
  if (!onFocusChange) return;
9334
9867
  if (focusedSessionId === "__new__") return;
@@ -9449,44 +9982,66 @@ function SessionsScreen({ sessions, currentId, focusedSessionId, onPick, onCreat
9449
9982
  flexDirection: "column",
9450
9983
  flexGrow: 1
9451
9984
  },
9452
- children: [/* @__PURE__ */ jsx$1("box", {
9453
- style: {
9454
- border: true,
9455
- borderColor: COLOR.border,
9456
- paddingLeft: 1,
9457
- paddingRight: 1,
9458
- height: 3,
9459
- flexShrink: 0,
9460
- marginBottom: 1
9461
- },
9462
- children: /* @__PURE__ */ jsx$1("input", {
9463
- ref: inputRef,
9464
- focused,
9465
- placeholder: "filter sessions — title or project…",
9466
- onInput: setQuery,
9467
- onSubmit: () => {},
9468
- style: { flexGrow: 1 }
9469
- })
9470
- }), /* @__PURE__ */ jsxs("scrollbox", {
9471
- focusable: false,
9472
- style: { flexGrow: 1 },
9473
- children: [rows.map((row, idx) => /* @__PURE__ */ jsx$1(SessionRow, {
9474
- row,
9475
- focused: idx === cursorIndex && focused,
9476
- isCurrent: row.kind === "session" && row.rowId === currentId,
9477
- showProject: showAllProjects,
9478
- currentProjectRoot
9479
- }, row.rowId)), filteredSessions.length === 0 && query.trim().length > 0 && /* @__PURE__ */ jsxs("text", {
9480
- wrapMode: "none",
9481
- children: [/* @__PURE__ */ jsx$1("span", {
9482
- fg: COLOR.mute,
9483
- children: " no sessions match "
9484
- }), /* @__PURE__ */ jsx$1("span", {
9485
- fg: COLOR.warn,
9486
- children: query.trim()
9985
+ children: [
9986
+ /* @__PURE__ */ jsx$1("box", {
9987
+ style: {
9988
+ border: true,
9989
+ borderColor: COLOR.border,
9990
+ paddingLeft: 1,
9991
+ paddingRight: 1,
9992
+ height: 3,
9993
+ flexShrink: 0,
9994
+ marginBottom: 1
9995
+ },
9996
+ children: /* @__PURE__ */ jsx$1("input", {
9997
+ id: "session-search-input",
9998
+ ref: inputRef,
9999
+ focused,
10000
+ placeholder: "search sessions — title, project, or message content…",
10001
+ onInput: setQuery,
10002
+ onSubmit: () => {},
10003
+ style: { flexGrow: 1 }
10004
+ })
10005
+ }),
10006
+ /* @__PURE__ */ jsxs("scrollbox", {
10007
+ ref: scrollboxRef,
10008
+ id: "sessions-scrollbox",
10009
+ focusable: false,
10010
+ style: { flexGrow: 1 },
10011
+ children: [rows.map((row, idx) => /* @__PURE__ */ jsx$1(SessionRow, {
10012
+ anchorId: `session-row-${idx}`,
10013
+ row,
10014
+ separator: idx > 0,
10015
+ focused: idx === cursorIndex && focused,
10016
+ isCurrent: row.kind === "session" && row.rowId === currentId,
10017
+ showProject: showAllProjects,
10018
+ currentProjectRoot
10019
+ }, row.rowId)), filteredSessions.length === 0 && query.trim().length > 0 && /* @__PURE__ */ jsxs("text", {
10020
+ wrapMode: "none",
10021
+ children: [
10022
+ /* @__PURE__ */ jsx$1("span", {
10023
+ fg: COLOR.mute,
10024
+ children: contentSearching ? " searching conversation history for " : " no sessions match "
10025
+ }),
10026
+ /* @__PURE__ */ jsx$1("span", {
10027
+ fg: COLOR.warn,
10028
+ children: query.trim()
10029
+ }),
10030
+ contentSearching && /* @__PURE__ */ jsx$1("span", {
10031
+ fg: COLOR.mute,
10032
+ children: "…"
10033
+ })
10034
+ ]
9487
10035
  })]
9488
- })]
9489
- })]
10036
+ }),
10037
+ /* @__PURE__ */ jsx$1("box", {
10038
+ style: {
10039
+ height: 1,
10040
+ flexShrink: 0
10041
+ },
10042
+ children: contentSearching && /* @__PURE__ */ jsx$1(Spinner, { label: `searching conversation history ${contentSearchProgress.done}/${contentSearchProgress.total}` })
10043
+ })
10044
+ ]
9490
10045
  }), /* @__PURE__ */ jsx$1(TitleOverlay, {
9491
10046
  title: "sessions",
9492
10047
  meta: titleMeta
@@ -9502,18 +10057,25 @@ function SessionsScreen({ sessions, currentId, focusedSessionId, onPick, onCreat
9502
10057
  * exactly the width of the focus + current markers above — so the first
9503
10058
  * stat (the turn count) sits flush under the title's first letter.
9504
10059
  */
9505
- function SessionRow({ row, focused, isCurrent, showProject = false, currentProjectRoot }) {
10060
+ function SessionRow({ row, anchorId, focused, isCurrent, separator = false, showProject = false, currentProjectRoot }) {
9506
10061
  const COLOR = useColors();
10062
+ const SURFACE = useSurfaces();
9507
10063
  const STATS_INDENT = " ";
9508
10064
  const focusMark = focused ? "▶ " : " ";
9509
10065
  const focusColor = focused ? COLOR.brand : COLOR.mute;
9510
10066
  const titleColor = focused ? COLOR.brand : COLOR.dim;
10067
+ const rowStyle = {
10068
+ flexDirection: "column",
10069
+ flexShrink: 0,
10070
+ alignSelf: "stretch",
10071
+ ...separator ? {
10072
+ border: ["top"],
10073
+ borderColor: COLOR.border
10074
+ } : {}
10075
+ };
9511
10076
  if (row.kind === "new") return /* @__PURE__ */ jsxs("box", {
9512
- style: {
9513
- flexDirection: "column",
9514
- flexShrink: 0,
9515
- alignSelf: "stretch"
9516
- },
10077
+ id: anchorId,
10078
+ style: rowStyle,
9517
10079
  children: [/* @__PURE__ */ jsxs("text", {
9518
10080
  wrapMode: "none",
9519
10081
  children: [
@@ -9545,11 +10107,8 @@ function SessionRow({ row, focused, isCurrent, showProject = false, currentProje
9545
10107
  const currentMark = isCurrent ? "● " : " ";
9546
10108
  const currentColor = isCurrent ? COLOR.accent : COLOR.mute;
9547
10109
  return /* @__PURE__ */ jsxs("box", {
9548
- style: {
9549
- flexDirection: "column",
9550
- flexShrink: 0,
9551
- alignSelf: "stretch"
9552
- },
10110
+ id: anchorId,
10111
+ style: rowStyle,
9553
10112
  children: [
9554
10113
  /* @__PURE__ */ jsxs("text", {
9555
10114
  wrapMode: "none",
@@ -9611,6 +10170,38 @@ function SessionRow({ row, focused, isCurrent, showProject = false, currentProje
9611
10170
  fg: COLOR.mute,
9612
10171
  children: STATS_INDENT
9613
10172
  }), renderProjectLabel(meta.projectRoot, currentProjectRoot, COLOR)]
10173
+ }),
10174
+ row.contentMatch && /* @__PURE__ */ jsxs("box", {
10175
+ style: {
10176
+ flexDirection: "column",
10177
+ flexShrink: 0,
10178
+ marginTop: 1,
10179
+ marginLeft: 4,
10180
+ paddingLeft: 1,
10181
+ border: ["left"],
10182
+ borderColor: COLOR.border
10183
+ },
10184
+ children: [/* @__PURE__ */ jsxs("text", {
10185
+ wrapMode: "none",
10186
+ children: [/* @__PURE__ */ jsx$1("span", {
10187
+ fg: COLOR.brand,
10188
+ children: "matches"
10189
+ }), /* @__PURE__ */ jsx$1("span", {
10190
+ fg: COLOR.mute,
10191
+ children: ` · ${row.contentMatch.matchedTurnIds.length} message${row.contentMatch.matchedTurnIds.length === 1 ? "" : "s"}`
10192
+ })]
10193
+ }), row.contentMatch.excerpts.slice(0, 3).map((excerpt, i) => /* @__PURE__ */ jsxs("text", {
10194
+ fg: COLOR.dim,
10195
+ wrapMode: "none",
10196
+ children: [/* @__PURE__ */ jsx$1("span", {
10197
+ fg: COLOR.mute,
10198
+ children: `${excerpt.role === "user" ? "you" : excerpt.role} · `
10199
+ }), highlightSegments(excerpt.text, excerpt.highlighted).map((segment, j) => /* @__PURE__ */ jsx$1("span", {
10200
+ fg: segment.highlighted ? COLOR.warn : COLOR.dim,
10201
+ ...segment.highlighted ? { bg: SURFACE.selection } : {},
10202
+ children: segment.text
10203
+ }, j))]
10204
+ }, `${excerpt.turnId}:${i}`))]
9614
10205
  })
9615
10206
  ]
9616
10207
  });
@@ -9708,7 +10299,10 @@ const MIME_BY_EXT = {
9708
10299
  * default-value reference at the prop destructure.
9709
10300
  */
9710
10301
  const EMPTY_QUEUED_MESSAGES = [];
9711
- function ChatScreen({ cwd, events, busy, compacting = false, queuedMessages = EMPTY_QUEUED_MESSAGES, queueSelectionIndex = null, queueShortcuts, onEnterQueueFromEmptyPrompt, settings, onSubmit, session, pending, onApproval, pendingInteraction, onInteraction, completionProviders, inlineAutocompleteProviders, onCompletionTab, enterSelectsCompletion, onPopupOpenChange, selectedTurnId, promptTriggerHints, attachImageBinding, editorBinding, sessionDetailsBinding, liveSession = null }) {
10302
+ function shouldRenderTranscriptSearch(searchMode, query) {
10303
+ return searchMode && query.trim().length > 0;
10304
+ }
10305
+ function ChatScreen({ cwd, events, busy, compacting = false, queuedMessages = EMPTY_QUEUED_MESSAGES, queueSelectionIndex = null, queueShortcuts, onEnterQueueFromEmptyPrompt, settings, onSubmit, session, pending, onApproval, pendingInteraction, onInteraction, completionProviders, inlineAutocompleteProviders, onCompletionTab, enterSelectsCompletion, onPopupOpenChange, selectedTurnId, transcriptSearchMode = false, transcriptSearchQuery = "", transcriptSearchEvents = [], transcriptSearchSearching = false, onTranscriptSearchModeChange, onTranscriptSearchQueryChange, onTranscriptSearchNavigate, onTranscriptSearchOpen, onLoadEarlierTranscript, promptTriggerHints, attachImageBinding, editorBinding, sessionDetailsBinding, liveSession = null }) {
9712
10306
  const COLOR = useColors();
9713
10307
  const titleText = session?.title ?? "New session";
9714
10308
  const sessionShortcutKey = formatBindingForDisplay(sessionDetailsBinding);
@@ -9766,6 +10360,8 @@ function ChatScreen({ cwd, events, busy, compacting = false, queuedMessages = EM
9766
10360
  hasStatusIcon
9767
10361
  ]);
9768
10362
  const userPrompts = useMemo(() => promptHistoryFromEvents(events), [events]);
10363
+ const transcriptSearchActive = shouldRenderTranscriptSearch(transcriptSearchMode, transcriptSearchQuery);
10364
+ const displayedEvents = transcriptSearchActive ? transcriptSearchEvents : events;
9769
10365
  const [completionPopupOpen, setCompletionPopupOpen] = useState(false);
9770
10366
  const handlePopupOpenChange = useCallback((open) => {
9771
10367
  setCompletionPopupOpen(open);
@@ -9793,10 +10389,13 @@ function ChatScreen({ cwd, events, busy, compacting = false, queuedMessages = EM
9793
10389
  visible: !fileEditPending
9794
10390
  },
9795
10391
  children: /* @__PURE__ */ jsx$1(Transcript, {
9796
- events,
10392
+ events: displayedEvents,
9797
10393
  settings,
9798
10394
  selectedTurnId: selectedTurnId ?? null,
9799
- busy: busy && !pending && !pendingInteraction
10395
+ searchQuery: transcriptSearchActive ? transcriptSearchQuery : void 0,
10396
+ searchPending: transcriptSearchSearching,
10397
+ busy: !transcriptSearchActive && busy && !pending && !pendingInteraction,
10398
+ onLoadEarlier: transcriptSearchActive ? void 0 : onLoadEarlierTranscript
9800
10399
  })
9801
10400
  }),
9802
10401
  /* @__PURE__ */ jsx$1(ExtensionStatusLine, {}),
@@ -9826,7 +10425,12 @@ function ChatScreen({ cwd, events, busy, compacting = false, queuedMessages = EM
9826
10425
  onCompletionTab,
9827
10426
  enterSelectsCompletion,
9828
10427
  onPopupOpenChange: handlePopupOpenChange,
9829
- selectMode: queueSelectionIndex != null ? "queue" : selectedTurnId != null ? "turn" : null,
10428
+ searchMode: transcriptSearchMode,
10429
+ onSearchModeChange: onTranscriptSearchModeChange,
10430
+ onSearchQueryChange: onTranscriptSearchQueryChange,
10431
+ onSearchNavigate: onTranscriptSearchNavigate,
10432
+ onSearchOpen: onTranscriptSearchOpen,
10433
+ selectMode: queueSelectionIndex != null ? "queue" : null,
9830
10434
  triggerHints: promptTriggerHints,
9831
10435
  busy,
9832
10436
  onEnterQueueFromEmpty: onEnterQueueFromEmptyPrompt,
@@ -10152,7 +10756,10 @@ const EMPTY_INLINE_PROVIDERS = [];
10152
10756
  * ghost feels immediate on a pause.
10153
10757
  */
10154
10758
  const INLINE_GHOST_DEBOUNCE_MS = 120;
10155
- function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocompleteProviders, onCompletionTab, enterSelectsCompletion, onPopupOpenChange, selectMode = null, triggerHints, busy = false, onEnterQueueFromEmpty, attachImageBinding, editorBinding }) {
10759
+ function isTranscriptSearchExitKey(event, cursorOffset) {
10760
+ return event.name === "escape" || (event.name === "backspace" || event.name === "delete") && !event.ctrl && !event.meta && !event.shift && cursorOffset === 0;
10761
+ }
10762
+ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocompleteProviders, onCompletionTab, enterSelectsCompletion, onPopupOpenChange, searchMode = false, onSearchModeChange, onSearchQueryChange, onSearchNavigate, onSearchOpen, selectMode = null, triggerHints, busy = false, onEnterQueueFromEmpty, attachImageBinding, editorBinding }) {
10156
10763
  const focused = useModalAwareFocus();
10157
10764
  const renderer = useRenderer();
10158
10765
  const COLOR = useColors();
@@ -10217,7 +10824,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10217
10824
  * Once the user enters history (up at top), we snapshot the draft and cycle.
10218
10825
  */
10219
10826
  const historyRef = useRef(null);
10220
- const completion = useCompletion(bufferState, completionProviders ?? EMPTY_PROVIDERS);
10827
+ const completion = useCompletion(bufferState, searchMode ? EMPTY_PROVIDERS : completionProviders ?? EMPTY_PROVIDERS);
10221
10828
  const popupOpen = completion.active != null && completion.items.length > 0;
10222
10829
  useEffect(() => {
10223
10830
  onPopupOpenChange?.(popupOpen);
@@ -10226,9 +10833,38 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10226
10833
  useChipHighlights(textareaRef, completion.references, chipStyle);
10227
10834
  const [shellMode, setShellMode] = useState(false);
10228
10835
  const shellModeRef = useRef(false);
10229
- shellModeRef.current = shellMode;
10230
- /** Last text pushed into bufferState — distinguishes "typed `!` on empty" from bulk sets. */
10836
+ const searchModeRef = useRef(searchMode);
10837
+ const previousSearchModeRef = useRef(searchMode);
10838
+ const preSearchTextRef = useRef(null);
10839
+ /** Last text pushed into bufferState — distinguishes typed sentinels from bulk sets. */
10231
10840
  const lastSyncedTextRef = useRef("");
10841
+ searchModeRef.current = searchMode;
10842
+ shellModeRef.current = shellMode;
10843
+ useEffect(() => {
10844
+ const textarea = textareaRef.current;
10845
+ if (!previousSearchModeRef.current && searchMode) {
10846
+ if (preSearchTextRef.current === null) {
10847
+ preSearchTextRef.current = textarea?.plainText ?? "";
10848
+ textarea?.clear();
10849
+ lastSyncedTextRef.current = "";
10850
+ setBufferState({
10851
+ text: "",
10852
+ cursor: 0
10853
+ });
10854
+ onSearchQueryChange?.("");
10855
+ }
10856
+ } else if (previousSearchModeRef.current && !searchMode) {
10857
+ const restored = preSearchTextRef.current ?? "";
10858
+ textarea?.setText(restored);
10859
+ lastSyncedTextRef.current = restored;
10860
+ setBufferState({
10861
+ text: restored,
10862
+ cursor: restored.length
10863
+ });
10864
+ preSearchTextRef.current = null;
10865
+ }
10866
+ previousSearchModeRef.current = searchMode;
10867
+ }, [searchMode, onSearchQueryChange]);
10232
10868
  /**
10233
10869
  * Flip shell mode with the ref mirror updated SYNCHRONOUSLY — callers
10234
10870
  * that mutate the buffer and re-run `syncBuffer` in the same tick
@@ -10238,6 +10874,15 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10238
10874
  shellModeRef.current = on;
10239
10875
  setShellMode(on);
10240
10876
  }, []);
10877
+ const exitSearchMode = useCallback(() => {
10878
+ searchModeRef.current = false;
10879
+ onSearchQueryChange?.("");
10880
+ onSearchModeChange?.(false);
10881
+ }, [onSearchModeChange, onSearchQueryChange]);
10882
+ useKeyboard((key) => {
10883
+ if (!focused) return;
10884
+ if (searchModeRef.current && key.name === "escape") exitSearchMode();
10885
+ });
10241
10886
  const [ghost, setGhost] = useState(null);
10242
10887
  const ghostRef = useRef(null);
10243
10888
  ghostRef.current = ghost;
@@ -10249,11 +10894,19 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10249
10894
  const syncBuffer = useCallback(() => {
10250
10895
  const ta = textareaRef.current;
10251
10896
  if (!ta) return;
10252
- if (!shellModeRef.current && ta.plainText === "!" && lastSyncedTextRef.current === "") {
10897
+ if (!searchModeRef.current && !shellModeRef.current && ta.plainText === "?" && lastSyncedTextRef.current === "") {
10898
+ ta.clear();
10899
+ preSearchTextRef.current = "";
10900
+ searchModeRef.current = true;
10901
+ onSearchModeChange?.(true);
10902
+ onSearchQueryChange?.("");
10903
+ }
10904
+ if (!searchModeRef.current && !shellModeRef.current && ta.plainText === "!" && lastSyncedTextRef.current === "") {
10253
10905
  ta.clear();
10254
10906
  setShellModeNow(true);
10255
10907
  }
10256
10908
  lastSyncedTextRef.current = ta.plainText;
10909
+ if (searchModeRef.current) onSearchQueryChange?.(ta.plainText);
10257
10910
  setBufferState({
10258
10911
  text: ta.plainText,
10259
10912
  cursor: ta.cursorOffset
@@ -10266,7 +10919,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10266
10919
  const inlineEnabled = settings.inlineAutocomplete && inlineProviders.length > 0;
10267
10920
  useEffect(() => {
10268
10921
  const atWordBoundary = bufferState.cursor === bufferState.text.length || /\s/.test(bufferState.text[bufferState.cursor]);
10269
- if (!inlineEnabled || popupOpen || selectMode !== null || shellMode || bufferState.text.length === 0 || !atWordBoundary) {
10922
+ if (!inlineEnabled || popupOpen || selectMode !== null || shellMode || searchMode || bufferState.text.length === 0 || !atWordBoundary) {
10270
10923
  setGhost(null);
10271
10924
  return;
10272
10925
  }
@@ -10299,6 +10952,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10299
10952
  popupOpen,
10300
10953
  selectMode,
10301
10954
  shellMode,
10955
+ searchMode,
10302
10956
  inlineProviders
10303
10957
  ]);
10304
10958
  /**
@@ -10334,6 +10988,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10334
10988
  ]);
10335
10989
  const submit = useCallback(() => {
10336
10990
  const value = textareaRef.current?.plainText ?? "";
10991
+ if (searchModeRef.current) return;
10337
10992
  if (shellMode) {
10338
10993
  if (!value.trim()) return;
10339
10994
  onSubmit(`!${value}`, completion.references, attachments);
@@ -10501,6 +11156,24 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10501
11156
  openExternalEditor();
10502
11157
  return;
10503
11158
  }
11159
+ if (searchModeRef.current) {
11160
+ if (!event.ctrl && !event.meta && !event.shift && (event.name === "up" || event.name === "down")) {
11161
+ event.preventDefault();
11162
+ onSearchNavigate?.(event.name === "up" ? -1 : 1);
11163
+ return;
11164
+ }
11165
+ if (!event.ctrl && !event.meta && !event.shift && event.name === "return") {
11166
+ event.preventDefault();
11167
+ onSearchOpen?.();
11168
+ return;
11169
+ }
11170
+ if (isTranscriptSearchExitKey(event, textareaRef.current?.cursorOffset ?? 0)) {
11171
+ event.preventDefault();
11172
+ exitSearchMode();
11173
+ return;
11174
+ }
11175
+ return;
11176
+ }
10504
11177
  if (event.name === "backspace" && !event.ctrl && !event.meta && !event.shift && shellModeRef.current && (textareaRef.current?.cursorOffset ?? 0) === 0) {
10505
11178
  event.preventDefault();
10506
11179
  setShellModeNow(false);
@@ -10611,7 +11284,10 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10611
11284
  editorBinding,
10612
11285
  openExternalEditor,
10613
11286
  attachments,
10614
- showNotice
11287
+ showNotice,
11288
+ onSearchNavigate,
11289
+ onSearchOpen,
11290
+ exitSearchMode
10615
11291
  ]);
10616
11292
  const ghostPosition = useMemo(() => {
10617
11293
  if (!ghost) return null;
@@ -10692,7 +11368,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10692
11368
  children: [/* @__PURE__ */ jsxs("box", {
10693
11369
  style: {
10694
11370
  border: true,
10695
- borderColor: selectMode ? COLOR.warn : shellMode ? COLOR.accent : COLOR.borderActive,
11371
+ borderColor: selectMode ? COLOR.warn : searchMode ? COLOR.warn : shellMode ? COLOR.accent : COLOR.borderActive,
10696
11372
  paddingLeft: 1,
10697
11373
  paddingRight: 1,
10698
11374
  height: boxHeight,
@@ -10703,7 +11379,7 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10703
11379
  focused: focused && !selectMode,
10704
11380
  keyBindings: TEXTAREA_BINDINGS,
10705
11381
  wrapMode: "word",
10706
- placeholder: selectMode === "turn" ? "— turn-select mode — press ⎋ to resume typing —" : selectMode === "queue" ? " queue-select mode — press ⎋ to resume typing —" : shellMode ? "Run a shell command…" : "Ask zidane…",
11382
+ placeholder: selectMode === "queue" ? "— queue-select mode — press ⎋ to resume typing —" : searchMode ? "Search this conversation…" : shellMode ? "Run a shell command…" : "Ask zidane…",
10707
11383
  syntaxStyle: chipStyle,
10708
11384
  style: {
10709
11385
  flexGrow: 1,
@@ -10733,12 +11409,13 @@ function PromptBlock({ userPrompts, onSubmit, completionProviders, inlineAutocom
10733
11409
  })]
10734
11410
  }), /* @__PURE__ */ jsx$1(PromptHints, {
10735
11411
  selectMode,
11412
+ searchMode,
10736
11413
  triggerHints,
10737
11414
  busy
10738
11415
  })]
10739
11416
  })
10740
11417
  ]
10741
- }), !selectMode && /* @__PURE__ */ jsx$1("box", {
11418
+ }), !selectMode && !searchMode && /* @__PURE__ */ jsx$1("box", {
10742
11419
  style: {
10743
11420
  position: "absolute",
10744
11421
  bottom: "100%",
@@ -10765,6 +11442,10 @@ const PROMPT_HINTS_NORMAL = [
10765
11442
  key: "↑↓",
10766
11443
  label: "history"
10767
11444
  },
11445
+ {
11446
+ key: "?",
11447
+ label: "search"
11448
+ },
10768
11449
  {
10769
11450
  key: "ctrl+s",
10770
11451
  label: "messages"
@@ -10793,8 +11474,8 @@ const PROMPT_HINTS_BUSY = [
10793
11474
  label: "abort"
10794
11475
  }
10795
11476
  ];
10796
- /** Prompt-box shortcuts in select-turn mode only the selection actions are valid. */
10797
- const PROMPT_HINTS_SELECT_TURN = [
11477
+ /** Conversation search is the message selector with an optional filter. */
11478
+ const PROMPT_HINTS_SEARCH = [
10798
11479
  {
10799
11480
  key: "↑↓",
10800
11481
  label: "navigate"
@@ -10839,18 +11520,19 @@ const PROMPT_HINTS_SELECT_QUEUE = [{
10839
11520
  * Swaps the hint set based on `selectMode` so the user sees only the
10840
11521
  * shortcuts that actually do something in the active mode.
10841
11522
  */
10842
- function PromptHints({ selectMode, triggerHints, busy = false }) {
11523
+ function PromptHints({ selectMode, searchMode = false, triggerHints, busy = false }) {
10843
11524
  const COLOR = useColors();
10844
11525
  const { settings } = useSettings();
10845
11526
  const { width: termWidth } = useTerminalDimensions();
10846
- const minimalResting = !selectMode && !busy && settings.uiMode === "minimal";
10847
- const primary = selectMode === "turn" ? PROMPT_HINTS_SELECT_TURN : selectMode === "queue" ? PROMPT_HINTS_SELECT_QUEUE : busy ? PROMPT_HINTS_BUSY : minimalResting ? EMPTY_HINTS : PROMPT_HINTS_NORMAL;
11527
+ const minimalResting = !selectMode && !searchMode && !busy && settings.uiMode === "minimal";
11528
+ const primary = searchMode ? PROMPT_HINTS_SEARCH : selectMode === "queue" ? PROMPT_HINTS_SELECT_QUEUE : busy ? PROMPT_HINTS_BUSY : minimalResting ? EMPTY_HINTS : PROMPT_HINTS_NORMAL;
10848
11529
  const hints = useMemo(() => {
10849
11530
  const budget = Math.max(0, termWidth - 5);
10850
11531
  const tooLongCombined = !!triggerHints && triggerHints.length > 0 && !selectMode && primary.length > 0 && hintsLength(primary) + 3 + hintsLength(triggerHints) > budget;
10851
11532
  return clipHintsToWidth(selectMode || !triggerHints || triggerHints.length === 0 || tooLongCombined ? primary : [...primary, ...triggerHints], budget);
10852
11533
  }, [
10853
11534
  selectMode,
11535
+ searchMode,
10854
11536
  primary,
10855
11537
  triggerHints,
10856
11538
  termWidth
@@ -15496,7 +16178,8 @@ const PREVIEW_CHAR_MAX = 8e3;
15496
16178
  * the Modal's own clamp; this is the cap on wide terminals so the modal
15497
16179
  * keeps a comfortable shape rather than stretching to the full height.
15498
16180
  */
15499
- const MAX_MODAL_HEIGHT = 28;
16181
+ const MAX_MODAL_HEIGHT = 46;
16182
+ const MAX_MODAL_WIDTH = 148;
15500
16183
  const EDIT_TEXTAREA_BINDINGS = (() => {
15501
16184
  return [
15502
16185
  ...defaultTextareaKeyBindings.filter((b) => b.name !== "return" && !(b.name === "a" && b.ctrl && !b.shift && !b.meta)),
@@ -15524,12 +16207,18 @@ const EDIT_TEXTAREA_BINDINGS = (() => {
15524
16207
  function editableText(turn) {
15525
16208
  return turn.content.filter((b) => b.type === "text").map((b) => b.text).join("\n\n");
15526
16209
  }
15527
- function TurnDetailsModal({ turn, index, total, actions, keybindings }) {
16210
+ function TurnDetailsModal({ turn, toolResultTurns = [], searchQuery, index, total, actions, keybindings }) {
15528
16211
  const COLOR = useColors();
16212
+ const SURFACE = useSurfaces();
15529
16213
  const modal = useModal();
15530
- const fullText = turnAsText(turn);
15531
- const preview = fullText.length > PREVIEW_CHAR_MAX ? `${fullText.slice(0, PREVIEW_CHAR_MAX)}\n\n…(${fullText.length - PREVIEW_CHAR_MAX} more chars)` : fullText;
15532
- const summary = blockSummary(turn);
16214
+ const { fullText, previewEntries, summary } = useMemo(() => {
16215
+ const previewTurns = [turn, ...toolResultTurns];
16216
+ return {
16217
+ fullText: previewTurns.map(turnAsText).filter(Boolean).join("\n\n"),
16218
+ previewEntries: buildPreviewEntries(previewTurns, PREVIEW_CHAR_MAX),
16219
+ summary: blockSummary(previewTurns)
16220
+ };
16221
+ }, [turn, toolResultTurns]);
15533
16222
  const bottomTitle = `${index - 1} before · ${total - index} after`;
15534
16223
  const [pending, setPending] = useState(null);
15535
16224
  const [copyStatus, setCopyStatus] = useState("idle");
@@ -15595,6 +16284,10 @@ function TurnDetailsModal({ turn, index, total, actions, keybindings }) {
15595
16284
  title: editing ? `edit turn ${index} / ${total} · ${turn.role}` : `turn ${index} / ${total} · ${turn.role}`,
15596
16285
  bottomTitle: editing ? void 0 : bottomTitle,
15597
16286
  maxHeight: MAX_MODAL_HEIGHT,
16287
+ maxWidth: MAX_MODAL_WIDTH,
16288
+ minWidth: 64,
16289
+ horizontalMargin: 2,
16290
+ verticalMargin: 1,
15598
16291
  disableEscape: editing || pending !== null,
15599
16292
  children: [
15600
16293
  !editing && /* @__PURE__ */ jsxs("text", {
@@ -15674,7 +16367,8 @@ function TurnDetailsModal({ turn, index, total, actions, keybindings }) {
15674
16367
  title: " preview ",
15675
16368
  style: {
15676
16369
  border: true,
15677
- borderColor: COLOR.mute,
16370
+ borderColor: COLOR.borderActive,
16371
+ backgroundColor: SURFACE.background,
15678
16372
  paddingLeft: 1,
15679
16373
  paddingRight: 1,
15680
16374
  flexDirection: "column",
@@ -15682,17 +16376,38 @@ function TurnDetailsModal({ turn, index, total, actions, keybindings }) {
15682
16376
  flexShrink: 1,
15683
16377
  minHeight: 5
15684
16378
  },
15685
- children: preview ? /* @__PURE__ */ jsx$1("scrollbox", {
16379
+ children: previewEntries.length > 0 ? /* @__PURE__ */ jsx$1("scrollbox", {
15686
16380
  focusable: false,
15687
- style: { flexGrow: 1 },
16381
+ style: {
16382
+ flexGrow: 1,
16383
+ rootOptions: { backgroundColor: SURFACE.background },
16384
+ contentOptions: { flexDirection: "column" }
16385
+ },
15688
16386
  stickyScroll: false,
15689
- children: /* @__PURE__ */ jsx$1("text", {
15690
- fg: COLOR.dim,
15691
- children: preview
15692
- })
16387
+ children: previewEntries.map((entry, i) => /* @__PURE__ */ jsxs("box", {
16388
+ style: {
16389
+ flexDirection: "column",
16390
+ marginBottom: i === previewEntries.length - 1 ? 0 : 1
16391
+ },
16392
+ children: [/* @__PURE__ */ jsx$1("text", {
16393
+ fg: entry.error ? COLOR.error : entry.kind === "tool-result" ? COLOR.accent : entry.kind === "tool-call" ? COLOR.warn : COLOR.model,
16394
+ children: /* @__PURE__ */ jsx$1(HighlightedPreviewText, {
16395
+ text: entry.label,
16396
+ query: searchQuery,
16397
+ foreground: entry.error ? COLOR.error : entry.kind === "tool-result" ? COLOR.accent : entry.kind === "tool-call" ? COLOR.warn : COLOR.model
16398
+ })
16399
+ }), entry.text && /* @__PURE__ */ jsx$1("text", {
16400
+ fg: entry.error ? COLOR.error : COLOR.dim,
16401
+ children: /* @__PURE__ */ jsx$1(HighlightedPreviewText, {
16402
+ text: entry.text,
16403
+ query: searchQuery,
16404
+ foreground: entry.error ? COLOR.error : COLOR.dim
16405
+ })
16406
+ })]
16407
+ }, `${entry.kind}:${i}`))
15693
16408
  }) : /* @__PURE__ */ jsx$1("text", {
15694
16409
  fg: COLOR.mute,
15695
- children: "— no text content —"
16410
+ children: "— no previewable content —"
15696
16411
  })
15697
16412
  }),
15698
16413
  editing ? /* @__PURE__ */ jsxs("text", {
@@ -15852,14 +16567,97 @@ function ActionRow({ pending, copyStatus, canCopy, canEdit, forkKey, deleteKey,
15852
16567
  ]
15853
16568
  });
15854
16569
  }
15855
- /**
15856
- * Human-readable per-kind block tally — e.g. `1 text · 2 tool_call`. Skips
15857
- * zero-count kinds so the line stays scannable; uses canonical block-type
15858
- * names so they line up with what the LLM and persistence layer see.
15859
- */
15860
- function blockSummary(turn) {
16570
+ function buildPreviewEntries(turns, limit) {
16571
+ const entries = [];
16572
+ let remaining = limit;
16573
+ let skipped = 0;
16574
+ const add = (entry) => {
16575
+ if (remaining <= 0) {
16576
+ skipped++;
16577
+ return;
16578
+ }
16579
+ const text = entry.text.length > remaining ? `${entry.text.slice(0, remaining)}\n… (${entry.text.length - remaining} more chars)` : entry.text;
16580
+ entries.push({
16581
+ ...entry,
16582
+ text
16583
+ });
16584
+ remaining -= Math.min(entry.text.length, remaining);
16585
+ };
16586
+ for (const turn of turns) for (const block of turn.content) if (block.type === "text") add({
16587
+ kind: "text",
16588
+ label: turn.role === "user" ? "you" : turn.role,
16589
+ text: block.text
16590
+ });
16591
+ else if (block.type === "thinking") add({
16592
+ kind: "thinking",
16593
+ label: "thinking",
16594
+ text: block.text
16595
+ });
16596
+ else if (block.type === "tool_call") add({
16597
+ kind: "tool-call",
16598
+ label: `tool call · ${block.name}`,
16599
+ text: stringifyPreview(block.input)
16600
+ });
16601
+ else if (block.type === "tool_result") add({
16602
+ kind: "tool-result",
16603
+ label: block.isError ? "tool result · error" : "tool result",
16604
+ text: toolResultPreview(block.output),
16605
+ error: block.isError
16606
+ });
16607
+ else if (block.type === "server_tool_use") add({
16608
+ kind: "tool-call",
16609
+ label: `server tool call · ${block.name}`,
16610
+ text: stringifyPreview(block.input)
16611
+ });
16612
+ else if (block.type === "server_tool_result") add({
16613
+ kind: "tool-result",
16614
+ label: `server tool result · ${block.toolName}`,
16615
+ text: stringifyPreview(block.content)
16616
+ });
16617
+ else if (block.type === "compact-summary") add({
16618
+ kind: "summary",
16619
+ label: `compaction summary · ${block.replacesTurnIds.length} turns`,
16620
+ text: block.summary
16621
+ });
16622
+ else if (block.type === "image" || block.type === "audio" || block.type === "video" || block.type === "document") add({
16623
+ kind: "attachment",
16624
+ label: `${block.type} attachment`,
16625
+ text: block.name ?? block.mediaType
16626
+ });
16627
+ else skipped++;
16628
+ if (skipped > 0) entries.push({
16629
+ kind: "summary",
16630
+ label: `… ${skipped} more block${skipped === 1 ? "" : "s"} not shown`,
16631
+ text: ""
16632
+ });
16633
+ return entries;
16634
+ }
16635
+ function HighlightedPreviewText({ text, query, foreground }) {
16636
+ const matchColor = resolveChipColor(useSurfaces().chips, "default");
16637
+ return /* @__PURE__ */ jsx$1(Fragment, { children: highlightSegments(text, query ? searchHighlightRanges(text, query) : []).map((segment, i) => /* @__PURE__ */ jsx$1("span", {
16638
+ fg: segment.highlighted ? matchColor.fg : foreground,
16639
+ bg: segment.highlighted ? matchColor.bg : void 0,
16640
+ children: segment.text
16641
+ }, i)) });
16642
+ }
16643
+ function toolResultPreview(output) {
16644
+ if (typeof output === "string") return output;
16645
+ return output.map((part) => {
16646
+ if (part.type === "text") return part.text;
16647
+ const name = "name" in part && part.name ? `: ${part.name}` : "";
16648
+ return `[${part.type} attachment${name}]`;
16649
+ }).join("\n");
16650
+ }
16651
+ function stringifyPreview(value) {
16652
+ try {
16653
+ return JSON.stringify(value, null, 2);
16654
+ } catch {
16655
+ return String(value);
16656
+ }
16657
+ }
16658
+ function blockSummary(turns) {
15861
16659
  const counts = {};
15862
- for (const block of turn.content) counts[block.type] = (counts[block.type] ?? 0) + 1;
16660
+ for (const turn of turns) for (const block of turn.content) counts[block.type] = (counts[block.type] ?? 0) + 1;
15863
16661
  const parts = [];
15864
16662
  for (const [type, n] of Object.entries(counts)) parts.push(`${n} ${type}`);
15865
16663
  return parts.length === 0 ? "(empty)" : parts.join(" · ");
@@ -15915,6 +16713,8 @@ function lastLines(text, n) {
15915
16713
  * and lstats — cheap enough that latency, not cost, picked the number.
15916
16714
  */
15917
16715
  const EXTENSION_WATCH_POLL_MS = 3e3;
16716
+ /** LRU bound for cross-session search indexes (full lowercased text per session). */
16717
+ const SESSION_SEARCH_INDEX_CACHE_MAX = 64;
15918
16718
  /**
15919
16719
  * Surface failures that are normally silenced (teardown / save) when the
15920
16720
  * `ZIDANE_DEBUG` env var is set. Logging via `console.error` would otherwise
@@ -16514,6 +17314,19 @@ function AppShell() {
16514
17314
  const pickedRef = useRef(picked);
16515
17315
  pickedRef.current = picked;
16516
17316
  const [sessions, setSessions] = useState([]);
17317
+ const [sessionContentMatches, setSessionContentMatches] = useState(/* @__PURE__ */ new Map());
17318
+ const [sessionContentSearching, setSessionContentSearching] = useState(false);
17319
+ const [sessionContentSearchProgress, setSessionContentSearchProgress] = useState({
17320
+ done: 0,
17321
+ total: 0
17322
+ });
17323
+ const sessionSearchTimerRef = useRef(null);
17324
+ const sessionSearchQueryRef = useRef("");
17325
+ const sessionSearchGenerationRef = useRef(0);
17326
+ const sessionSearchAbortRef = useRef(null);
17327
+ const historySearchWorkerRef = useRef(null);
17328
+ const sessionSearchCacheRef = useRef(/* @__PURE__ */ new Map());
17329
+ const liveSessionSearchIndexRef = useRef(null);
16517
17330
  const [currentSession, setCurrentSession] = useState(null);
16518
17331
  const [chatReferenceScope, setChatReferenceScope] = useState("project");
16519
17332
  const [projectChatCatalog, setProjectChatCatalog] = useState([]);
@@ -16523,6 +17336,16 @@ function AppShell() {
16523
17336
  const allChatCatalogRef = useRef(allChatCatalog);
16524
17337
  allChatCatalogRef.current = allChatCatalog;
16525
17338
  const [events, setEvents] = useState([]);
17339
+ const eventsRef = useRef(events);
17340
+ eventsRef.current = events;
17341
+ /**
17342
+ * True while the user is browsing revealed history (`onLoadEarlierTranscript`)
17343
+ * and no new run has started. Suspends the stream buffer's display cap so a
17344
+ * trailing smooth-mode drain (typewriter backlog outliving `runningRef`)
17345
+ * can't re-trim the page that was just loaded. Cleared wherever a run
17346
+ * starts and on session switch (`stream.reset()`), where the cap resumes.
17347
+ */
17348
+ const transcriptRevealHoldRef = useRef(false);
16526
17349
  const [busy, setBusy] = useState(false);
16527
17350
  /**
16528
17351
  * The provider+model the run actually advanced to after a `model:fallback`
@@ -16766,12 +17589,17 @@ function AppShell() {
16766
17589
  */
16767
17590
  const lastInputTokensRef = useRef(0);
16768
17591
  /**
16769
- * Active turn id when the user is in "select turn" mode (ctrl+s on the
16770
- * chat screen). `null` means normal mode typing is enabled, transcript
16771
- * has no highlight. When set, the prompt textarea is unfocused so up/down
16772
- * navigate the turn list, ↵ opens the details modal, ⎋ exits the mode.
17592
+ * Active turn in conversation-search mode. The prompt remains focused for
17593
+ * filtering while plain arrows navigate and Enter opens turn details.
16773
17594
  */
16774
17595
  const [selectedTurnId, setSelectedTurnId] = useState(null);
17596
+ const [transcriptSearchMode, setTranscriptSearchMode] = useState(false);
17597
+ const [transcriptSearchQuery, setTranscriptSearchQuery] = useState("");
17598
+ const [transcriptSearchResults, setTranscriptSearchResults] = useState([]);
17599
+ const [transcriptSearchSearching, setTranscriptSearchSearching] = useState(false);
17600
+ const transcriptSearchGenerationRef = useRef(0);
17601
+ const transcriptSearchAbortRef = useRef(null);
17602
+ const transcriptSearchIndexRef = useRef(null);
16775
17603
  /**
16776
17604
  * Highlighted entry in the type-ahead queue box. `null` means the user
16777
17605
  * is in normal mode (textarea focused, queue purely informational);
@@ -17043,7 +17871,10 @@ function AppShell() {
17043
17871
  * writes a title to a torn-down session.
17044
17872
  */
17045
17873
  const autoTitleAbortRef = useRef(null);
17046
- const stream = useStreamBuffer(setEvents, { getSmooth: useCallback(() => smoothStreamingRef.current, []) });
17874
+ const stream = useStreamBuffer(setEvents, {
17875
+ getSmooth: useCallback(() => smoothStreamingRef.current, []),
17876
+ maxTranscriptEvents: transcriptRevealHoldRef.current ? null : void 0
17877
+ });
17047
17878
  streamRef.current = stream;
17048
17879
  const makePicked = useCallback((provider, modelId) => {
17049
17880
  const descriptor = providerRegistry[provider.key];
@@ -17802,6 +18633,113 @@ function AppShell() {
17802
18633
  const toggleSessionsScope = useCallback(() => {
17803
18634
  setSessionsScopeAll(!sessionsShowAll);
17804
18635
  }, [sessionsShowAll]);
18636
+ /** Debounced, cached full-history search for the conversations list. */
18637
+ const onSessionSearchQueryChange = useCallback((raw) => {
18638
+ sessionSearchQueryRef.current = raw;
18639
+ const query = raw.trim();
18640
+ const generation = ++sessionSearchGenerationRef.current;
18641
+ sessionSearchAbortRef.current?.abort();
18642
+ const abortController = new AbortController();
18643
+ sessionSearchAbortRef.current = abortController;
18644
+ if (sessionSearchTimerRef.current) clearTimeout(sessionSearchTimerRef.current);
18645
+ if (!query) {
18646
+ setSessionContentMatches(/* @__PURE__ */ new Map());
18647
+ setSessionContentSearching(false);
18648
+ setSessionContentSearchProgress({
18649
+ done: 0,
18650
+ total: 0
18651
+ });
18652
+ return;
18653
+ }
18654
+ setSessionContentMatches(/* @__PURE__ */ new Map());
18655
+ setSessionContentSearching(true);
18656
+ setSessionContentSearchProgress({
18657
+ done: 0,
18658
+ total: sessions.length
18659
+ });
18660
+ sessionSearchTimerRef.current = setTimeout(() => {
18661
+ (async () => {
18662
+ const matches = /* @__PURE__ */ new Map();
18663
+ let cursor = 0;
18664
+ let done = 0;
18665
+ const worker = async () => {
18666
+ while (cursor < sessions.length && generation === sessionSearchGenerationRef.current) {
18667
+ const meta = sessions[cursor++];
18668
+ try {
18669
+ let index;
18670
+ const live = sessionRef.current;
18671
+ const cached = sessionSearchCacheRef.current.get(meta.id);
18672
+ if (live?.id === meta.id) {
18673
+ const cachedLive = liveSessionSearchIndexRef.current;
18674
+ if (cachedLive?.sessionId === meta.id && cachedLive.updatedAt === meta.updatedAt && cachedLive.turnCount === live.turns.length) index = cachedLive.index;
18675
+ else {
18676
+ index = await (historySearchWorkerRef.current ??= new HistorySearchWorkerClient()).build(live.turns, abortController.signal);
18677
+ liveSessionSearchIndexRef.current = {
18678
+ sessionId: meta.id,
18679
+ updatedAt: meta.updatedAt,
18680
+ turnCount: live.turns.length,
18681
+ index
18682
+ };
18683
+ }
18684
+ } else if (cached?.updatedAt === meta.updatedAt) {
18685
+ index = cached.index;
18686
+ sessionSearchCacheRef.current.delete(meta.id);
18687
+ sessionSearchCacheRef.current.set(meta.id, cached);
18688
+ } else {
18689
+ const turns = await store.getTurns(meta.id);
18690
+ index = await (historySearchWorkerRef.current ??= new HistorySearchWorkerClient()).build(turns, abortController.signal);
18691
+ const cache = sessionSearchCacheRef.current;
18692
+ cache.set(meta.id, {
18693
+ updatedAt: meta.updatedAt,
18694
+ index
18695
+ });
18696
+ while (cache.size > SESSION_SEARCH_INDEX_CACHE_MAX) {
18697
+ const oldest = cache.keys().next().value;
18698
+ if (oldest === void 0) break;
18699
+ cache.delete(oldest);
18700
+ }
18701
+ }
18702
+ if (generation !== sessionSearchGenerationRef.current) return;
18703
+ const match = await (historySearchWorkerRef.current ??= new HistorySearchWorkerClient()).history(index, query, 3, abortController.signal);
18704
+ if (match) matches.set(meta.id, match);
18705
+ } catch (error) {
18706
+ if (abortController.signal.aborted) throw abortController.signal.reason instanceof DOMException ? abortController.signal.reason : error;
18707
+ debugLog("session content search skipped a session", error);
18708
+ }
18709
+ done++;
18710
+ if (done === sessions.length || done % 4 === 0) setSessionContentSearchProgress({
18711
+ done,
18712
+ total: sessions.length
18713
+ });
18714
+ }
18715
+ };
18716
+ try {
18717
+ await Promise.all(Array.from({ length: Math.min(6, sessions.length) }, worker));
18718
+ } catch (error) {
18719
+ if (!(error instanceof DOMException && error.name === "AbortError")) throw error;
18720
+ return;
18721
+ }
18722
+ if (generation !== sessionSearchGenerationRef.current) return;
18723
+ setSessionContentMatches(matches);
18724
+ setSessionContentSearchProgress({
18725
+ done: sessions.length,
18726
+ total: sessions.length
18727
+ });
18728
+ setSessionContentSearching(false);
18729
+ })().catch((error) => {
18730
+ debugLog("session history search failed", error);
18731
+ if (generation === sessionSearchGenerationRef.current) setSessionContentSearching(false);
18732
+ });
18733
+ }, 120);
18734
+ }, [sessions, store]);
18735
+ useEffect(() => {
18736
+ if (sessionSearchQueryRef.current.trim()) onSessionSearchQueryChange(sessionSearchQueryRef.current);
18737
+ }, [sessions, onSessionSearchQueryChange]);
18738
+ useEffect(() => () => {
18739
+ if (sessionSearchTimerRef.current) clearTimeout(sessionSearchTimerRef.current);
18740
+ sessionSearchAbortRef.current?.abort();
18741
+ historySearchWorkerRef.current?.terminate();
18742
+ }, []);
17805
18743
  const refreshSessions = useCallback(async () => {
17806
18744
  const list = await listSessionMeta(store, sessionsShowAll ? void 0 : { projectRoot: projectDir });
17807
18745
  setSessions(list);
@@ -17846,6 +18784,7 @@ function AppShell() {
17846
18784
  debugLog("teardown: auto-title abort failed", err);
17847
18785
  }
17848
18786
  autoTitleAbortRef.current = null;
18787
+ transcriptRevealHoldRef.current = false;
17849
18788
  stream.reset();
17850
18789
  await agentRef.current?.destroy().catch((err) => debugLog("agent.destroy failed", err));
17851
18790
  agentRef.current = null;
@@ -17909,6 +18848,7 @@ function AppShell() {
17909
18848
  await session.appendTurns([turn]);
17910
18849
  setEvents(capTranscriptEvents(eventsFromTurns(session.turns, session.runs)));
17911
18850
  runningRef.current = true;
18851
+ transcriptRevealHoldRef.current = false;
17912
18852
  setBusy(true);
17913
18853
  setActiveFallback(null);
17914
18854
  try {
@@ -18310,6 +19250,25 @@ function AppShell() {
18310
19250
  const onPopupOpenChange = useCallback((open) => {
18311
19251
  popupOpenRef.current = open;
18312
19252
  }, []);
19253
+ /**
19254
+ * Reveal one persisted-history page when Transcript reaches its top marker.
19255
+ * No full-history array is retained: materialize from Session on demand,
19256
+ * slice, then let the temporary array go. A running stream deliberately
19257
+ * declines — its first next append would re-cap anyway, and changing the
19258
+ * head while buckets are draining makes scroll preservation misleading.
19259
+ */
19260
+ const onLoadEarlierTranscript = useCallback(() => {
19261
+ if (runningRef.current) return false;
19262
+ const session = sessionRef.current;
19263
+ const displayed = eventsRef.current;
19264
+ if (!session || hiddenEarlierTranscriptCount(displayed) <= 0) return false;
19265
+ const next = revealEarlierTranscriptEvents(eventsFromTurns(session.turns, session.runs), displayed);
19266
+ if (next === displayed) return false;
19267
+ transcriptRevealHoldRef.current = true;
19268
+ eventsRef.current = next;
19269
+ setEvents(next);
19270
+ return true;
19271
+ }, []);
18313
19272
  const onAbort = useCallback(() => {
18314
19273
  if (popupOpenRef.current) return;
18315
19274
  commandAbortRef.current?.abort();
@@ -19249,6 +20208,7 @@ function AppShell() {
19249
20208
  if (!agent || !session || !picked) return;
19250
20209
  const wasRunning = runningRef.current;
19251
20210
  if (!wasRunning) runningRef.current = true;
20211
+ transcriptRevealHoldRef.current = false;
19252
20212
  suppressWakeRef.current = false;
19253
20213
  (async () => {
19254
20214
  let effectivePrompt = prompt;
@@ -19399,6 +20359,7 @@ function AppShell() {
19399
20359
  if (suppressWakeRef.current) return;
19400
20360
  if (!agent.hasPendingTaskNotifications) return;
19401
20361
  runningRef.current = true;
20362
+ transcriptRevealHoldRef.current = false;
19402
20363
  (async () => {
19403
20364
  setBusy(true);
19404
20365
  try {
@@ -19607,25 +20568,143 @@ function AppShell() {
19607
20568
  }, [modal, config.paths.userDir]);
19608
20569
  const hasMultipleAgents = useMemo(() => Object.keys(liveAgentRegistry).length > 1, [liveAgentRegistry]);
19609
20570
  const turnIds = useMemo(() => selectableTurnIds(events, settings), [events, settings]);
20571
+ useEffect(() => {
20572
+ const query = transcriptSearchQuery.trim();
20573
+ const generation = ++transcriptSearchGenerationRef.current;
20574
+ transcriptSearchAbortRef.current?.abort();
20575
+ if (!transcriptSearchMode || !query) {
20576
+ setTranscriptSearchResults([]);
20577
+ setTranscriptSearchSearching(false);
20578
+ return;
20579
+ }
20580
+ const session = sessionRef.current;
20581
+ if (!session) {
20582
+ setTranscriptSearchResults([]);
20583
+ setTranscriptSearchSearching(false);
20584
+ return;
20585
+ }
20586
+ const turns = session.turns;
20587
+ const abortController = new AbortController();
20588
+ transcriptSearchAbortRef.current = abortController;
20589
+ setTranscriptSearchSearching(true);
20590
+ (async () => {
20591
+ const cached = transcriptSearchIndexRef.current;
20592
+ const index = cached?.sessionId === session.id && cached.events === events ? cached.index : await (historySearchWorkerRef.current ??= new HistorySearchWorkerClient()).build(turns, abortController.signal);
20593
+ transcriptSearchIndexRef.current = {
20594
+ sessionId: session.id,
20595
+ events,
20596
+ index
20597
+ };
20598
+ const matches = await (historySearchWorkerRef.current ??= new HistorySearchWorkerClient()).search(index, query, turns.length, abortController.signal);
20599
+ if (generation !== transcriptSearchGenerationRef.current) return;
20600
+ const order = new Map(turns.map((turn, i) => [turn.id, i]));
20601
+ matches.sort((a, b) => (order.get(a.turn.id) ?? 0) - (order.get(b.turn.id) ?? 0));
20602
+ setTranscriptSearchResults(matches);
20603
+ setTranscriptSearchSearching(false);
20604
+ })().catch((error) => {
20605
+ if (!(error instanceof DOMException && error.name === "AbortError")) {
20606
+ debugLog("conversation search failed", error);
20607
+ if (generation === transcriptSearchGenerationRef.current) setTranscriptSearchSearching(false);
20608
+ }
20609
+ });
20610
+ return () => abortController.abort();
20611
+ }, [
20612
+ transcriptSearchMode,
20613
+ transcriptSearchQuery,
20614
+ events
20615
+ ]);
20616
+ const transcriptSearchOwnerIds = useMemo(() => {
20617
+ const owners = /* @__PURE__ */ new Map();
20618
+ if (!transcriptSearchQuery.trim() || transcriptSearchResults.length === 0) return owners;
20619
+ const session = sessionRef.current;
20620
+ if (!session) return owners;
20621
+ const callOwner = /* @__PURE__ */ new Map();
20622
+ for (const turn of session.turns) for (const block of turn.content) if (block.type === "tool_call") callOwner.set(block.id, turn.id);
20623
+ for (const result of transcriptSearchResults) {
20624
+ const resultOwners = [];
20625
+ for (const block of result.turn.content) {
20626
+ if (block.type !== "tool_result") continue;
20627
+ const owner = callOwner.get(block.callId);
20628
+ if (owner && owner !== result.turn.id && !resultOwners.includes(owner)) resultOwners.push(owner);
20629
+ }
20630
+ if (resultOwners.length > 0) owners.set(result.turn.id, resultOwners);
20631
+ }
20632
+ return owners;
20633
+ }, [
20634
+ transcriptSearchQuery,
20635
+ transcriptSearchResults,
20636
+ events
20637
+ ]);
20638
+ const transcriptSearchEvents = useMemo(() => {
20639
+ if (!transcriptSearchMode || !transcriptSearchQuery.trim()) return [];
20640
+ const session = sessionRef.current;
20641
+ if (!session) return [];
20642
+ const shown = transcriptSearchResults.slice(-200);
20643
+ const contextIds = new Set(shown.map((result) => result.turn.id));
20644
+ for (const result of shown) for (const owner of transcriptSearchOwnerIds.get(result.turn.id) ?? []) contextIds.add(owner);
20645
+ const searchEvents = eventsFromTurns(session.turns.filter((turn) => contextIds.has(turn.id)), session.runs);
20646
+ const hidden = transcriptSearchResults.length - shown.length;
20647
+ if (hidden > 0) searchEvents.unshift({
20648
+ kind: "info",
20649
+ text: `… ${hidden} earlier matched message${hidden === 1 ? "" : "s"} not shown — refine the query to narrow results.`
20650
+ });
20651
+ return searchEvents;
20652
+ }, [
20653
+ transcriptSearchMode,
20654
+ transcriptSearchQuery,
20655
+ transcriptSearchResults,
20656
+ transcriptSearchOwnerIds,
20657
+ events
20658
+ ]);
20659
+ const transcriptSearchTurnIds = useMemo(() => {
20660
+ if (!transcriptSearchQuery.trim()) return turnIds;
20661
+ return selectableTurnIds(transcriptSearchEvents, settings, (event) => isVisibleDuringSearch(event, settings, transcriptSearchQuery));
20662
+ }, [
20663
+ transcriptSearchQuery,
20664
+ transcriptSearchEvents,
20665
+ turnIds,
20666
+ settings
20667
+ ]);
19610
20668
  /** Drop the selection if its turn disappeared (session swap, history reset). */
19611
20669
  useEffect(() => {
20670
+ if (transcriptSearchMode) return;
19612
20671
  if (selectedTurnId && !turnIds.includes(selectedTurnId)) setSelectedTurnId(null);
19613
- }, [selectedTurnId, turnIds]);
19614
- const inSelectMode = selectedTurnId !== null;
19615
- const enterSelectMode = useCallback(() => {
19616
- if (turnIds.length === 0) return;
19617
- setSelectedTurnId(turnIds[turnIds.length - 1]);
20672
+ }, [
20673
+ selectedTurnId,
20674
+ turnIds,
20675
+ transcriptSearchMode
20676
+ ]);
20677
+ useEffect(() => {
20678
+ if (!transcriptSearchMode) return;
20679
+ if (transcriptSearchTurnIds.length === 0) {
20680
+ setSelectedTurnId(null);
20681
+ return;
20682
+ }
20683
+ if (!selectedTurnId || !transcriptSearchTurnIds.includes(selectedTurnId)) setSelectedTurnId(transcriptSearchTurnIds[transcriptSearchTurnIds.length - 1] ?? null);
20684
+ }, [
20685
+ transcriptSearchMode,
20686
+ transcriptSearchTurnIds,
20687
+ selectedTurnId
20688
+ ]);
20689
+ const onTranscriptSearchModeChange = useCallback((enabled) => {
20690
+ setTranscriptSearchMode(enabled);
20691
+ setTranscriptSearchQuery("");
20692
+ if (enabled) (historySearchWorkerRef.current ??= new HistorySearchWorkerClient()).prewarm();
20693
+ if (!enabled) setSelectedTurnId(null);
20694
+ else if (turnIds.length > 0) setSelectedTurnId(turnIds[turnIds.length - 1] ?? null);
19618
20695
  }, [turnIds]);
19619
- const cycleSelectedTurn = useCallback((direction) => {
20696
+ const cycleSelectedTurn = useCallback((direction, candidates = turnIds) => {
19620
20697
  setSelectedTurnId((prev) => {
19621
- if (!prev || turnIds.length === 0) return prev;
19622
- const idx = turnIds.indexOf(prev);
19623
- if (idx === -1) return turnIds[turnIds.length - 1];
19624
- const len = turnIds.length;
19625
- const nextIdx = ((idx + direction) % len + len) % len;
19626
- return turnIds[nextIdx];
20698
+ if (!prev || candidates.length === 0) return prev;
20699
+ const idx = candidates.indexOf(prev);
20700
+ if (idx === -1) return candidates[candidates.length - 1];
20701
+ const len = candidates.length;
20702
+ return candidates[((idx + direction) % len + len) % len];
19627
20703
  });
19628
20704
  }, [turnIds]);
20705
+ const onTranscriptSearchNavigate = useCallback((direction) => {
20706
+ cycleSelectedTurn(direction, transcriptSearchTurnIds);
20707
+ }, [cycleSelectedTurn, transcriptSearchTurnIds]);
19629
20708
  const onForkTurn = useCallback(async (turnId) => {
19630
20709
  const source = sessionRef.current;
19631
20710
  if (!source || !picked) return;
@@ -19665,7 +20744,10 @@ function AppShell() {
19665
20744
  debugLog("delete: save failed", err);
19666
20745
  return;
19667
20746
  }
19668
- setEvents(capTranscriptEvents(eventsFromTurns(session.turns, session.runs)));
20747
+ const nextEvents = capTranscriptEvents(eventsFromTurns(session.turns, session.runs));
20748
+ const nextIds = selectableTurnIds(nextEvents, settings);
20749
+ setEvents(nextEvents);
20750
+ setSelectedTurnId((prev) => prev ? selectionAfterTurnRemoval(turnIds, prev, nextIds) : prev);
19669
20751
  const replayedTokens = lastContextSizeFromTurns(session.turns, session.runs);
19670
20752
  setLastInputTokens(replayedTokens);
19671
20753
  lastInputTokensRef.current = replayedTokens;
@@ -19676,11 +20758,7 @@ function AppShell() {
19676
20758
  userMessageCount: session.turns.reduce((n, t) => t.role === "user" ? n + 1 : n, 0),
19677
20759
  updatedAt: Date.now()
19678
20760
  } : prev);
19679
- setSelectedTurnId((prev) => {
19680
- if (!prev) return prev;
19681
- return nextTurns.some((t) => t.id === prev) ? prev : null;
19682
- });
19683
- }, []);
20761
+ }, [settings, turnIds]);
19684
20762
  const onEditTurn = useCallback(async (turnId, newText) => {
19685
20763
  const session = sessionRef.current;
19686
20764
  if (!session) return;
@@ -19702,7 +20780,10 @@ function AppShell() {
19702
20780
  debugLog("edit: save failed", err);
19703
20781
  return;
19704
20782
  }
19705
- setEvents(capTranscriptEvents(eventsFromTurns(session.turns, session.runs)));
20783
+ const rebuilt = eventsFromTurns(session.turns, session.runs);
20784
+ const patched = replaceTranscriptTurnEvents(eventsRef.current, rebuilt, turnId);
20785
+ setEvents(patched ?? capTranscriptEvents(rebuilt));
20786
+ setSelectedTurnId(turnId);
19706
20787
  const replayedTokens = lastContextSizeFromTurns(session.turns, session.runs);
19707
20788
  setLastInputTokens(replayedTokens);
19708
20789
  lastInputTokensRef.current = replayedTokens;
@@ -20000,11 +21081,23 @@ function AppShell() {
20000
21081
  if (!session) return;
20001
21082
  const turn = session.turns.find((t) => t.id === id);
20002
21083
  if (!turn) return;
20003
- const index = turnIds.indexOf(id) + 1;
21084
+ const candidates = transcriptSearchMode ? transcriptSearchTurnIds : turnIds;
21085
+ const index = candidates.indexOf(id) + 1;
21086
+ const callIds = new Set(turn.content.filter((block) => block.type === "tool_call").map((block) => block.id));
21087
+ const toolResultTurns = callIds.size === 0 ? [] : session.turns.flatMap((candidate) => {
21088
+ if (candidate.id === turn.id) return [];
21089
+ const content = candidate.content.filter((block) => block.type === "tool_result" && callIds.has(block.callId));
21090
+ return content.length > 0 ? [{
21091
+ ...candidate,
21092
+ content
21093
+ }] : [];
21094
+ });
20004
21095
  modal.open(/* @__PURE__ */ jsx$1(TurnDetailsModal, {
20005
21096
  turn,
21097
+ toolResultTurns,
21098
+ searchQuery: transcriptSearchMode ? transcriptSearchQuery : void 0,
20006
21099
  index,
20007
- total: turnIds.length,
21100
+ total: candidates.length,
20008
21101
  actions: {
20009
21102
  onFork: onForkTurn,
20010
21103
  onDelete: onDeleteTurn,
@@ -20016,6 +21109,9 @@ function AppShell() {
20016
21109
  modal,
20017
21110
  selectedTurnId,
20018
21111
  turnIds,
21112
+ transcriptSearchMode,
21113
+ transcriptSearchQuery,
21114
+ transcriptSearchTurnIds,
20019
21115
  onForkTurn,
20020
21116
  onDeleteTurn,
20021
21117
  onEditTurn,
@@ -20023,23 +21119,8 @@ function AppShell() {
20023
21119
  ]);
20024
21120
  useKeyboard((key) => {
20025
21121
  if (modal.isOpen) return;
20026
- if (inSelectMode && screen === "chat") {
20027
- if (key.name === "up") {
20028
- cycleSelectedTurn(-1);
20029
- return;
20030
- }
20031
- if (key.name === "down") {
20032
- cycleSelectedTurn(1);
20033
- return;
20034
- }
20035
- if (key.name === "return") {
20036
- openSelectedTurn();
20037
- return;
20038
- }
20039
- if (key.name === "escape") {
20040
- setSelectedTurnId(null);
20041
- return;
20042
- }
21122
+ if (transcriptSearchMode && screen === "chat") {
21123
+ if (key.name === "escape") onTranscriptSearchModeChange(false);
20043
21124
  return;
20044
21125
  }
20045
21126
  if (queueSelectionIndex != null && screen === "chat") {
@@ -20204,8 +21285,8 @@ function AppShell() {
20204
21285
  }));
20205
21286
  return;
20206
21287
  }
20207
- if (matchesBinding(key, keybindings.enterSelectTurnMode) && screen === "chat" && !busy && !pendingApproval && !pendingInteraction) {
20208
- enterSelectMode();
21288
+ if (matchesBinding(key, keybindings.enterSelectTurnMode) && screen === "chat" && turnIds.length > 0 && !busy && !pendingApproval && !pendingInteraction) {
21289
+ onTranscriptSearchModeChange(true);
20209
21290
  return;
20210
21291
  }
20211
21292
  if (matchesBinding(key, keybindings.cycleAgent) && screen === "chat" && hasMultipleAgents && !busy && !pendingApproval && !pendingInteraction) {
@@ -20496,6 +21577,10 @@ function AppShell() {
20496
21577
  onPick: onSwitchSession,
20497
21578
  onCreate: onCreateSession,
20498
21579
  onFocusChange: setFocusedSessionId,
21580
+ contentMatches: sessionContentMatches,
21581
+ contentSearching: sessionContentSearching,
21582
+ contentSearchProgress: sessionContentSearchProgress,
21583
+ onSearchQueryChange: onSessionSearchQueryChange,
20499
21584
  showAllProjects: sessionsShowAll,
20500
21585
  onToggleScope: toggleSessionsScope,
20501
21586
  currentProjectRoot: projectDir
@@ -20527,6 +21612,15 @@ function AppShell() {
20527
21612
  enterSelectsCompletion: (providerId) => providerId === "chats",
20528
21613
  onPopupOpenChange,
20529
21614
  selectedTurnId,
21615
+ transcriptSearchMode,
21616
+ transcriptSearchQuery,
21617
+ transcriptSearchEvents,
21618
+ transcriptSearchSearching,
21619
+ onTranscriptSearchModeChange,
21620
+ onTranscriptSearchQueryChange: setTranscriptSearchQuery,
21621
+ onTranscriptSearchNavigate,
21622
+ onTranscriptSearchOpen: openSelectedTurn,
21623
+ onLoadEarlierTranscript,
20530
21624
  promptTriggerHints,
20531
21625
  attachImageBinding: keybindings.attachClipboardImage,
20532
21626
  editorBinding: settings.externalEditor ? keybindings.openExternalEditor : void 0,
@@ -20538,7 +21632,7 @@ function AppShell() {
20538
21632
  hints,
20539
21633
  context: contextUsage,
20540
21634
  cost: footerCost,
20541
- status: pendingInteraction?.kind === "question" ? "asking" : compacting ? "compacting" : busy ? "busy" : null,
21635
+ status: transcriptSearchMode && transcriptSearchSearching ? "searching" : pendingInteraction?.kind === "question" ? "asking" : compacting ? "compacting" : busy ? "busy" : null,
20542
21636
  compactionTokens,
20543
21637
  compactionStartedAt
20544
21638
  }),