Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
0ede752
feat(cli): fold completed read/search tool batches into the thought line
DragonnZhang Aug 19, 2026
d567c7e
fix(cli): address review feedback on thought/tool-group merge
DragonnZhang Aug 20, 2026
2ad0de6
test(cli): pin thought/tool-group merge deferral behavior
DragonnZhang Aug 20, 2026
31ccec7
chore: merge main into feat/thought-tool-group-merge
DragonnZhang Aug 20, 2026
8adf1a7
fix(cli): resolve thought merge deferral on interleaved thought, add …
DragonnZhang Aug 20, 2026
04796cd
Merge remote-tracking branch 'origin/main' into feat/thought-tool-gro…
DragonnZhang Aug 20, 2026
bc24dc1
fix(cli): harden thought-merge deferral ownership against divergent b…
DragonnZhang Aug 20, 2026
ae91fdf
fix(cli): harden thought-merge deferral against concurrent streams
DragonnZhang Aug 21, 2026
8a940e0
fix(cli): commit concurrent-stream thoughts on settlement, dedupe for…
DragonnZhang Aug 21, 2026
4681e7b
fix(cli): Static-path merged-batch suppression, anchor restore, and t…
DragonnZhang Aug 21, 2026
5c73a8c
Merge remote-tracking branch 'origin/main' into pr-9503
DragonnZhang Aug 21, 2026
f752f08
fix(cli): collision-free prompt_id mints, owner-aware cancel settleme…
DragonnZhang Aug 22, 2026
3cf4666
fix(cli): keep detached ?btw continuations from overwriting the cance…
DragonnZhang Aug 25, 2026
07f8e95
Merge remote-tracking branch 'origin/main' into feat/thought-tool-gro…
DragonnZhang Aug 26, 2026
3e4bb90
fix(cli): close the R9 foreign-tail and Cron-drain deferral holes
DragonnZhang Aug 27, 2026
29b2d40
fix(cli): close the R10 settlement/reset ownership gaps
DragonnZhang Aug 27, 2026
36a1aee
Merge remote-tracking branch 'origin/main' into feat/thought-tool-gro…
DragonnZhang Aug 28, 2026
04d90b9
fix(cli): settle the pre-query deferral owner-aware for every submit …
DragonnZhang Aug 28, 2026
2bfbe68
Merge remote-tracking branch 'origin/main' into feat/thought-tool-gro…
DragonnZhang Aug 28, 2026
980031d
fix(cli): complete the Gemini→Llm identifier migration after main mer…
DragonnZhang Aug 28, 2026
f10ff79
fix(cli): drop unnecessary abortThoughtMergeDeferral dep from submitQ…
DragonnZhang Aug 28, 2026
d37e4cc
fix(cli): clear merge-deferral state when history is replaced (R12-1)…
DragonnZhang Aug 28, 2026
f4192a3
fix(cli): seed prompt counter from max persisted snapshot suffix on r…
DragonnZhang Aug 28, 2026
630a3d7
fix(cli): commit a concurrent stream own thought at its TCR boundary …
DragonnZhang Aug 29, 2026
cfc1fa5
fix(cli): pass owner as string|undefined to commitOwnPendingThought (…
DragonnZhang Aug 29, 2026
7665fb0
Merge remote-tracking branch 'origin/main' into feat/thought-tool-gro…
DragonnZhang Aug 30, 2026
010ab55
fix(cli): restore missing closing brace in finishReasonInfoMessage af…
DragonnZhang Aug 30, 2026
3eda818
fix(cli): skip tool_use_summary emission for thought-merged batches (…
DragonnZhang Aug 30, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
181 changes: 181 additions & 0 deletions docs/design/thought-tool-group-merge.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
# Merge completed read/search tool batches into the thought line

## Problem

When the model thinks and then calls tools, the TUI renders a collapsed
thought line (`∴ Thought for 5s (ctrl+o to expand)`) followed by the tool
batch. The vertical spacing below the thought line is inconsistent because it
is owned by the _next_ history item's `marginTop`
(`getHistoryItemMarginTop` in `HistoryItemDisplay.tsx`): a fresh `gemini`
assistant block adds a blank line, while `tool_group` / `gemini_content` sit
tight against it. The same "Thought for …" line therefore sometimes has a gap
below it and sometimes not.

Claude Code solves this differently: information-gathering batches that follow
thinking collapse into the thought line itself once they finish —
`Thought for 9s, searched for 2 patterns (ctrl+o to expand)` — while the tools
render normally while they run. This removes the spacing inconsistency for the
dominant case (think → read/search) and keeps scrollback dense.

## Current state

Live turn flow (`useGeminiStream.ts`):

1. Streamed reasoning lives in `pendingThoughtItem` (dynamic area).
2. On the first `ToolCallRequest`, `commitPendingThought` commits the thought
to history → Ink `<Static>` → **frozen, can never be re-rendered**.
3. Tools execute; the live group is `pendingToolCallGroupDisplay` (dynamic).
4. When the batch completes, the scheduler's `onComplete` commits the finished
`tool_group` via `addItem` → also frozen.

Because step 2 freezes the thought line before step 4 knows whether the batch
is mergeable, the merge must be decided **at commit time**, which requires
deferring the thought commit.

Rendering facts that shape the design:

- Both the `<Static>` path and the virtual-viewport (VP) path render items
one-by-one through `HistoryItemDisplay`; VP additionally assumes one data
entry = one visual row. Cross-item render-time merging is therefore not
viable; the merge must be expressed on the committed items themselves.
- Interactive resume (`resumeHistoryUtils`) deliberately does **not**
reintroduce thought rows, so no resume-path merging is needed. Only the
standalone picker preview emits them (thoughts expanded, no merge).
- `tool_group` items committed by the scheduler are adjacent to the thought
by timing, not structure. The merge does not need to validate adjacency:
`onComplete` commits the thought and the group back-to-back, so nothing
can land between them. Items committed asynchronously while the batch runs
(e.g. a `memory_saved` notification) may land above the merged line —
harmless, and consistent with them landing above today's separate lines.
- Existing building blocks: `isCollapsibleTool` / `buildToolSummary`
(`CompactToolGroupDisplay.tsx`) already classify read/search/list tools and
phrase summaries ("Searched 2 patterns", "Read a.ts, b.ts"); the
`HistoryItemBase.display` bag is the established home for display-only
flags; `isHistoryItemVisibleAfterRestore` is the precedent for filtering
committed items out of the rendered list.

## Proposed change

### 1. Defer the thought commit across tool execution

In the `ToolCallRequest` handler, when the pending thought is a single
`gemini_thought` head (not a `gemini_thought_content` tail of a split
oversized thought), do not commit it. Instead mark a merge deferral active,
freeze the thought's duration on the pending item, and flag the item
`finalized: true` so the dynamic area renders the completed style
(`∴ Thought for 5s`, therefore-icon, no live "Thinking…" tick) while tools run
below it.

`commitPendingThought` becomes deferral-aware: while a deferral is active it
is a no-op, so the existing blanket calls (stream `finally`, Finished,
thought→content transition) keep the thought pending until the deferral is
resolved.

### 2. Resolve the deferral at batch completion

In the scheduler `onComplete` callback (where the finished `tool_group` is
committed), evaluate the merge predicate:

- deferral active and `pendingThoughtItem` is still a `gemini_thought` head;
- every tool in the batch is collapsible (`isCollapsibleTool`), has status
Success, and carries no inline images / omitted-image overflow;
- no managed-memory ops in the batch (their "Recalled/Wrote N memories" badge
would be swallowed);
- nothing interleaved (the pending thought was never committed, which is the
only way another item could have landed between it and the batch).

If the predicate holds:

- commit the thought item with a new `toolSummary` field holding
`buildToolSummary(tools, isActive=false)`;
- commit the `tool_group` item as usual, plus the display-only flag
`display.mergedIntoThought = true`.

Otherwise (error/cancel/partial batch, edit/shell/agent tools, deferral absent
e.g. oversized-thought tail): commit the thought normally, then the group —
today's behavior.

The deferral must also resolve safely on every non-completion exit:
user cancel, stream error, non-continuation retry, model fallback (commit the
thought normally), and the post-loop scheduling decision when no executable
tools were scheduled at all (e.g. duplicate-suppressed batch).

### 3. Render the merged line

`ThinkMessage` (ConversationMessages.tsx) gains `toolSummary?: string`:
collapsed and expanded labels append `, <summary>` (leading letter lowercased,
mirroring `buildToolSummary`'s own multi-category join):

```
∴ Thought for 9s, searched 2 patterns (ctrl+o to expand)
```

`HistoryItemDisplay` renders nothing for a `tool_group` with
`display.mergedIntoThought` unless `fullDetail` is on. To keep VP height
accounting clean, `MainContent` filters merged-away groups out of the rendered
list (same place it applies `isHistoryItemVisibleAfterRestore`), except in
full detail. There is no separate transcript snapshot: Ctrl+O toggles
`fullDetail` and remounts `<Static>`, re-running the same filter.

Full-detail (Ctrl+O) therefore shows everything: the thought expanded plus the
tool group rendered with forced expansion/results. Clicking the merged line
expands the thought body in place (per-item expansion); the global Ctrl+O /
Alt+T toggle is one and the same full-detail switch (both keys are bound to
`Command.TOGGLE_THINKING_EXPANDED`), so it opens full detail and re-admits the
merged-away tool group too — consistent with the existing philosophy that
read/search/list results are disposable (the same partition that already
collapses them into a summary line today).

### 4. Data model

| Item | Change |
| -------------------------- | ------------------------------------------------------------------------------------------------- |
| `HistoryItemGeminiThought` | + `toolSummary?: string`, + `finalized?: boolean` (pending-phase completed styling) |
| `HistoryItemToolGroup` | display flag `display.mergedIntoThought?: boolean` via the existing `HistoryItemBase.display` bag |

Both fields are additive and display-only; history semantics, session
persistence (model parts), SDK stream messages, and `/export` are unchanged.

## Files affected

| Area | Files |
| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Stream/commit logic | `packages/cli/src/ui/hooks/useGeminiStream.ts` (ToolCallRequest handler, `commitPendingThought`, scheduler `onComplete`, `cancelOngoingRequest`, and the error/retry/fallback/new-prompt resolve points) |
| Types | `packages/cli/src/ui/types.ts` |
| Rendering | `packages/cli/src/ui/components/messages/ConversationMessages.tsx` (`ThinkMessage`), `packages/cli/src/ui/components/HistoryItemDisplay.tsx` (suppressed group), `packages/cli/src/ui/components/MainContent.tsx` (filter) |
| Tests | `useGeminiStream.test.tsx` (commit-timing assertions change), `ConversationMessages.test.tsx`, `HistoryItemDisplay.test.tsx` |

## Scope boundaries

- In scope: the main conversation, live turns only (commit-time merge).
- Out of scope (documented follow-ups):
- subagent chat view (`agentHistoryAdapter`) — has the same adjacency but a
separate rendering surface;
- merging batches that contain edit/command/agent tools (those stay
expanded, as in Claude Code);
- merging across multiple consecutive batches in one thought (only the batch
immediately resolved with the deferred thought merges; later batches
render as today);
- the residual spacing difference between a collapsed thought followed by a
non-mergeable tool group (tight) vs. followed by text (blank line). After
this change the dominant read/search case is merged; the remaining cases
match Claude Code's tight tool rendering.

## Key risks

- `useGeminiStream` commit ordering is subtle; every path that commits or
discards pending state must resolve the deferral. Mitigation: deferral is a
single ref checked by `commitPendingThought`; resolution is centralized in
onComplete plus a small set of explicit fallback commits, each covered by a
unit test.
- Existing unit tests assert the thought commits at tool-call start; those
assertions move to batch completion (or normal commit for non-mergeable
batches).
- The deferred thought renders in the dynamic area during tool execution (one
extra line re-rendered per tick) — negligible; tool groups already do this.

## Open questions

None blocking. Label phrasing uses the existing `buildToolSummary` output
(English verbs by precedent); locale behavior matches today's compact tool
summaries.
70 changes: 70 additions & 0 deletions packages/cli/src/ui/AppContainer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5644,6 +5644,76 @@ describe('AppContainer State Management', () => {
).toBe(true);
});

it('seeds the prompt counter from the max persisted snapshot suffix on resume (R13-1)', async () => {
// Retry/Teammate submits mint prompt-id suffixes without persisting a
// counted user record, so seeding only from the user-record count
// re-mints an already-used suffix after any retry: Q1 (suffix 0) ->
// error retry (suffix 1, no user record) -> resume (seed would be 1)
// -> the next query mints suffix 1 again, colliding with the retry's
// file-history snapshot (last-occurrence-wins lookups would then
// resolve rewind/diffs to the wrong turn).
const seedPromptCount = vi.fn();
mockedUseSessionStats.mockReturnValue({
stats: {},
seedPromptCount,
});
mockedUseHistory.mockReturnValue({
history: [] as HistoryItem[],
addItem: vi.fn(),
updateItem: vi.fn(),
clearItems: vi.fn(),
loadHistory: vi.fn(),
truncateToItem: vi.fn(),
});
vi.spyOn(mockConfig, 'getContentGenerator').mockReturnValue(
{} as unknown as ReturnType<typeof mockConfig.getContentGenerator>,
);
vi.spyOn(mockConfig, 'initialize').mockResolvedValue(undefined);
vi.spyOn(mockConfig, 'loadPausedBackgroundAgents').mockResolvedValue([]);
vi.spyOn(mockConfig, 'getResumedSessionData').mockReturnValue({
conversation: {
sessionId: 'session-1',
projectHash: 'test-project-hash',
startTime: '2024-01-01T00:00:00Z',
lastUpdated: '2024-01-01T00:00:01Z',
messages: [
{
uuid: 'u1',
parentUuid: null,
sessionId: 'session-1',
timestamp: '2024-01-01T00:00:00Z',
type: 'user',
message: { role: 'user', parts: [{ text: 'hello' }] },
cwd: '/test/workspace',
version: '1.0.0',
},
],
},
// One user record, but snapshots stamped up to suffix 1 by the retry.
fileHistorySnapshots: [
{ promptId: 'session-1########0', trackedFileBackups: {} },
{ promptId: 'session-1########1', trackedFileBackups: {} },
],
filePath: '/tmp/session.jsonl',
lastCompletedUuid: 'u1',
} as ReturnType<typeof mockConfig.getResumedSessionData>);

render(
<AppContainer
config={mockConfig}
settings={mockSettings}
version="1.0.0"
initializationResult={mockInitResult}
/>,
);

// max(userTurnCount=1, maxSuffix+1=2) — without the snapshot-derived
// seed this would have been called with 1.
await vi.waitFor(() => {
expect(seedPromptCount).toHaveBeenCalledWith(2);
});
});

it('does not remeasure footer height for sticky todo status-only updates', async () => {
// Scoped stub: makeFakeConfig().initialize() rejects on React's
// double-mount, which leaks async renders and destabilizes the
Expand Down
21 changes: 19 additions & 2 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1104,8 +1104,25 @@ export const AppContainer = (props: AppContainerProps) => {
m.subtype !== 'mid_turn_user_message' &&
m.subtype !== 'realtime_message',
).length;
if (userTurnCount > 0) {
seedPromptCount(userTurnCount);
// R13-1: the user-record count alone under-seeds whenever Retry or
// Teammate submits consumed prompt-id suffixes without persisting a
// counted user record — after any such retry, the first post-resume
// UserQuery would re-mint an already-used suffix, and file-history
// lookups are last-occurrence-wins, so rewind/diffs would resolve to
// the wrong turn. Seed from the maximum persisted snapshot suffix as
// well: snapshots are the prompt_id-keyed state the collision harms.
let maxPersistedSuffix = -1;
for (const snapshot of resumedSessionData.fileHistorySnapshots ?? []) {
const separatorIndex = snapshot.promptId.lastIndexOf('########');
if (separatorIndex === -1) continue;
const suffixText = snapshot.promptId.slice(separatorIndex + 8);
if (!/^\d+$/.test(suffixText)) continue;
const suffix = Number(suffixText);
if (suffix > maxPersistedSuffix) maxPersistedSuffix = suffix;
}
const promptCountSeed = Math.max(userTurnCount, maxPersistedSuffix + 1);
if (promptCountSeed > 0) {
seedPromptCount(promptCountSeed);
}

const recovered = await config.loadPausedBackgroundAgents(
Expand Down
74 changes: 74 additions & 0 deletions packages/cli/src/ui/components/HistoryItemDisplay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -577,6 +577,80 @@ describe('<HistoryItemDisplay />', () => {
expect(passedProps.fullDetail).toBe(true);
});

it('renders a merged tool summary on the thought line', () => {
const item: HistoryItem = {
id: 1,
type: 'gemini_thought',
text: 'Inspecting the repository',
durationMs: 9000,
toolSummary: 'Searched 2 patterns',
};

const { lastFrame } = renderWithProviders(
<HistoryItemDisplay item={item} terminalWidth={100} isPending={false} />,
);

const output = lastFrame() ?? '';
expect(output).toContain('Thought for 9s, searched 2 patterns');
expect(output).not.toContain('Inspecting the repository');
});

it('renders a merged-away tool_group as nothing outside full detail', () => {
vi.mocked(ToolGroupMessage).mockClear();
const item: HistoryItem = {
id: 1,
type: 'tool_group',
tools: [
{
callId: '123',
name: 'grep_search',
description: 'pattern: alpha',
resultDisplay: 'found',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
display: { mergedIntoThought: true },
};

const { lastFrame } = renderWithProviders(
<HistoryItemDisplay item={item} terminalWidth={80} isPending={false} />,
);

expect(vi.mocked(ToolGroupMessage)).not.toHaveBeenCalled();
expect((lastFrame() ?? '').trim()).toBe('');
});

it('renders a merged-away tool_group in full detail', () => {
vi.mocked(ToolGroupMessage).mockClear();
const item: HistoryItem = {
id: 1,
type: 'tool_group',
tools: [
{
callId: '123',
name: 'grep_search',
description: 'pattern: alpha',
resultDisplay: 'found',
status: ToolCallStatus.Success,
confirmationDetails: undefined,
},
],
display: { mergedIntoThought: true },
};

renderWithProviders(
<HistoryItemDisplay
item={item}
terminalWidth={80}
isPending={false}
fullDetail
/>,
);

expect(vi.mocked(ToolGroupMessage)).toHaveBeenCalledTimes(1);
});

describe('showTimestamps', () => {
const timestampItem: HistoryItem = {
...baseItem,
Expand Down
Loading
Loading