@pellux/goodvibes-tui 2.0.3 → 2.0.4
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.
- package/CHANGELOG.md +19 -0
- package/README.md +1 -1
- package/docs/foundation-artifacts/operator-contract.json +1 -1
- package/package.json +8 -8
- package/src/core/conversation-compaction-render.ts +15 -15
- package/src/core/conversation-fold.ts +101 -0
- package/src/core/conversation-line-cache.ts +4 -2
- package/src/core/conversation-rendering.ts +43 -54
- package/src/renderer/conversation-surface.ts +43 -22
- package/src/version.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,25 @@ All notable changes to GoodVibes TUI.
|
|
|
4
4
|
|
|
5
5
|
---
|
|
6
6
|
|
|
7
|
+
## [2.0.4] - 2026-08-01
|
|
8
|
+
|
|
9
|
+
### Changes
|
|
10
|
+
|
|
11
|
+
- **Collapsed output is compact.** A folded block — tool result, thinking
|
|
12
|
+
block, or compaction handoff — now renders as exactly one row: its header,
|
|
13
|
+
with the `▸ N lines` badge as the only count and the head of the content
|
|
14
|
+
riding the same line (thinking folds keep their content behind the toggle
|
|
15
|
+
by design). The frame rows, the interior preview row with its second
|
|
16
|
+
hidden-count marker, and the blank rows between consecutive folded results
|
|
17
|
+
are gone: three collapsed tool results that took nineteen rows now take
|
|
18
|
+
ten. Expanded rendering is unchanged. The fold decisions — threshold,
|
|
19
|
+
default, separators, preview rule — now come from the shared platform
|
|
20
|
+
policy in `@pellux/goodvibes-terminal-shell` (2.0.3), the same module the
|
|
21
|
+
Agent reads, so the two renderers cannot drift apart again.
|
|
22
|
+
- Fixed: a client that inherited the daemon's bind host is no longer refused
|
|
23
|
+
as "insecure PUBLIC transport" when that host is a wildcard (platform
|
|
24
|
+
runtime 2.0.3).
|
|
25
|
+
|
|
7
26
|
## [2.0.3] - 2026-08-01
|
|
8
27
|
|
|
9
28
|
### Changes
|
package/README.md
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
-
[](https://github.com/mgd34msu/goodvibes-tui)
|
|
6
6
|
|
|
7
7
|
GoodVibes is a terminal console for coding and operations work with an AI model. You run `goodvibes` in a project directory and get a full-screen terminal app where you talk to a model that can read and edit your files, run shell commands, search the web, and hand work off to background agents — asking your permission before anything that writes or executes. It talks to many model providers (OpenAI, Anthropic, Gemini, Bedrock, Copilot, OpenRouter and other OpenAI-compatible gateways, plus local servers like Ollama and LM Studio that it finds on startup), keeps its settings, sessions, and secrets on your own machine, and shows you the token count and running cost of every turn. Alongside the conversation, panels turn background work — running agents, git state, diffs, tokens, cost — into live control rooms.
|
|
8
8
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@pellux/goodvibes-tui",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.4",
|
|
4
4
|
"description": "Terminal-native GoodVibes client for coding, operations, knowledge and channel workflows — a pure client of the GoodVibes daemon.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/main.ts",
|
|
@@ -95,11 +95,11 @@
|
|
|
95
95
|
"@anthropic-ai/bedrock-sdk": "^0.29.1",
|
|
96
96
|
"@anthropic-ai/sdk": "^0.92.0",
|
|
97
97
|
"@ast-grep/napi": "^0.42.0",
|
|
98
|
-
"@pellux/goodvibes-sdk": "2.0.
|
|
99
|
-
"@pellux/goodvibes-terminal-shell": "2.0.
|
|
98
|
+
"@pellux/goodvibes-sdk": "2.0.3",
|
|
99
|
+
"@pellux/goodvibes-terminal-shell": "2.0.3",
|
|
100
100
|
"bash-language-server": "^5.6.0",
|
|
101
101
|
"fuse.js": "^7.1.0",
|
|
102
|
-
"@pellux/goodvibes-daemon": "1.28.
|
|
102
|
+
"@pellux/goodvibes-daemon": "1.28.5",
|
|
103
103
|
"graphql": "^16.13.2",
|
|
104
104
|
"jszip": "^3.10.1",
|
|
105
105
|
"node-edge-tts": "^1.2.10",
|
|
@@ -124,10 +124,10 @@
|
|
|
124
124
|
"typescript": "^5.9.3"
|
|
125
125
|
},
|
|
126
126
|
"optionalDependencies": {
|
|
127
|
-
"@pellux/goodvibes-tui-linux-x64": "2.0.
|
|
128
|
-
"@pellux/goodvibes-tui-linux-arm64": "2.0.
|
|
129
|
-
"@pellux/goodvibes-tui-darwin-x64": "2.0.
|
|
130
|
-
"@pellux/goodvibes-tui-darwin-arm64": "2.0.
|
|
127
|
+
"@pellux/goodvibes-tui-linux-x64": "2.0.4",
|
|
128
|
+
"@pellux/goodvibes-tui-linux-arm64": "2.0.4",
|
|
129
|
+
"@pellux/goodvibes-tui-darwin-x64": "2.0.4",
|
|
130
|
+
"@pellux/goodvibes-tui-darwin-arm64": "2.0.4"
|
|
131
131
|
},
|
|
132
132
|
"trustedDependencies": [],
|
|
133
133
|
"overrides": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { activeTheme } from '../renderer/theme.ts';
|
|
11
11
|
import { renderMarkdownTracked } from '../renderer/markdown.ts';
|
|
12
|
-
import {
|
|
12
|
+
import { renderConversationEventLine, renderConversationFoldedRow } from '../renderer/conversation-surface.ts';
|
|
13
13
|
import { GLYPHS } from '../renderer/ui-primitives.ts';
|
|
14
14
|
import type { Line } from '@pellux/goodvibes-sdk/platform/types';
|
|
15
15
|
import type { BlockMeta } from './conversation-types.ts';
|
|
@@ -49,30 +49,30 @@ export function renderCompactionContinuationMessage(
|
|
|
49
49
|
context.collapseState.set(collapseKey, true);
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
52
|
+
const tone = {
|
|
53
53
|
marker: GLYPHS.status.active,
|
|
54
54
|
markerFg: T.toolAccent,
|
|
55
55
|
label: 'compaction handoff',
|
|
56
56
|
labelFg: T.toolAccent,
|
|
57
57
|
detailFg: '244',
|
|
58
|
-
}
|
|
58
|
+
};
|
|
59
|
+
const details = [
|
|
59
60
|
{ text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
|
|
60
|
-
]
|
|
61
|
+
];
|
|
61
62
|
|
|
62
63
|
if (isCollapsed) {
|
|
63
|
-
|
|
64
|
-
|
|
64
|
+
// One row. This used to be the header PLUS a framed fragment holding a
|
|
65
|
+
// single static sentence — three rows of chrome to say what the label and
|
|
66
|
+
// the badge already said. The distinguishing half of that sentence folds
|
|
67
|
+
// onto the header instead.
|
|
68
|
+
context.history.addLine(renderConversationFoldedRow(
|
|
65
69
|
width,
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
bodyBg: T.collapsedBodyBg,
|
|
71
|
-
dim: true,
|
|
72
|
-
},
|
|
73
|
-
);
|
|
74
|
-
context.history.addLines(rendered);
|
|
70
|
+
tone,
|
|
71
|
+
details,
|
|
72
|
+
're-injected instructions + session summary',
|
|
73
|
+
));
|
|
75
74
|
} else {
|
|
75
|
+
context.history.addLine(renderConversationEventLine(width, tone, details));
|
|
76
76
|
context.history.addLines(renderMarkdownTracked(content, width).lines);
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* conversation-fold.ts — which transcript rows render FOLDED, and what spacing
|
|
3
|
+
* that earns them.
|
|
4
|
+
*
|
|
5
|
+
* A folded tool result is exactly ONE row: its header, with the preview riding
|
|
6
|
+
* on the same line after the `▸ N lines` badge. It used to be four rows — a
|
|
7
|
+
* `▄▄▄` cap, an interior preview line carrying a second `[▸ N hidden]` count, a
|
|
8
|
+
* `▀▀▀` cap, and a blank after all of it — for one line of text the header had
|
|
9
|
+
* already sized.
|
|
10
|
+
*
|
|
11
|
+
* These predicates live in their own leaf module because TWO callers must agree
|
|
12
|
+
* on them and neither may depend on the other: the row renderer
|
|
13
|
+
* (conversation-rendering.ts) decides how to draw the row, and the line cache
|
|
14
|
+
* (conversation-line-cache.ts) decides the blank separator that follows it. If
|
|
15
|
+
* those two ever disagreed about which rows are folded, a warm cache and a cold
|
|
16
|
+
* rebuild would space the transcript differently — the exact class of bug the
|
|
17
|
+
* cache's byte-identical contract exists to rule out.
|
|
18
|
+
*
|
|
19
|
+
* Everything here is PURE: collapse state is read, never written, so a row can
|
|
20
|
+
* be asked about before it has rendered.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { foldedToolResult, trailingBlankAfterRow } from '@pellux/goodvibes-terminal-shell';
|
|
24
|
+
import { summarizeToolResult } from '../renderer/tool-result-summary.ts';
|
|
25
|
+
import { isTurnCollapsed, type ConversationRenderContext } from './conversation-render-context.ts';
|
|
26
|
+
import { collapseKeyForNode, type RenderNode } from './conversation-turn-structure.ts';
|
|
27
|
+
import type { ConversationMessageSnapshot } from '@pellux/goodvibes-sdk/platform/core';
|
|
28
|
+
|
|
29
|
+
/** The subset of a render context these predicates read. */
|
|
30
|
+
type FoldContext = Pick<ConversationRenderContext, 'assistantTurns' | 'collapseState'>;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Does a tool-result row render FOLDED — one compact header row with its preview
|
|
34
|
+
* on it — rather than header-plus-expanded-body?
|
|
35
|
+
*
|
|
36
|
+
* An unset collapse key answers the same as the default the row itself will
|
|
37
|
+
* store (collapsed), so asking before and asking after the row renders give the
|
|
38
|
+
* same answer.
|
|
39
|
+
*/
|
|
40
|
+
export function isToolResultFolded(
|
|
41
|
+
message: Extract<ConversationMessageSnapshot, { role: 'tool' }>,
|
|
42
|
+
collapseState: ReadonlyMap<string, boolean>,
|
|
43
|
+
collapseKey: string,
|
|
44
|
+
): boolean {
|
|
45
|
+
// Threading only — the decision, the short-content threshold and the
|
|
46
|
+
// unset-key default all belong to foldedToolResult(). summarizeToolResult is
|
|
47
|
+
// this product's own renderer concern, so resolving it stays here: a
|
|
48
|
+
// summarizable result stays folded even when short, because the one-line
|
|
49
|
+
// summary is the better row and the raw payload belongs behind the toggle.
|
|
50
|
+
return foldedToolResult({
|
|
51
|
+
contentLength: message.content.length,
|
|
52
|
+
hasSummary: summarizeToolResult(message.toolName, message.content) !== null,
|
|
53
|
+
storedCollapsed: collapseState.get(collapseKey),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** A row that belongs to a tool run: the call itself, or the result under it. */
|
|
58
|
+
function isToolRunNode(node: RenderNode): boolean {
|
|
59
|
+
return node.kind === 'toolcall' || node.message.role === 'tool';
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Does this planned row emit exactly one folded tool-result line?
|
|
64
|
+
*
|
|
65
|
+
* A collapsed turn hides the row entirely, so there is no folded row to butt up
|
|
66
|
+
* against the next one.
|
|
67
|
+
*/
|
|
68
|
+
function rendersFoldedToolRow(node: RenderNode, context: FoldContext): boolean {
|
|
69
|
+
if (node.kind === 'toolcall') return false;
|
|
70
|
+
const message = node.message;
|
|
71
|
+
if (message.role !== 'tool') return false;
|
|
72
|
+
if (isTurnCollapsed(context.assistantTurns?.get(node.absIdx), context.collapseState)) return false;
|
|
73
|
+
return isToolResultFolded(message, context.collapseState, collapseKeyForNode(node));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* The blank separator that follows a planned row.
|
|
78
|
+
*
|
|
79
|
+
* Branch rows sit tight under their parent, so the blank lands only after the
|
|
80
|
+
* last row of a top-level unit — that is what keeps a turn's whole subtree
|
|
81
|
+
* reading as one block. On top of that, a folded row followed by more tool
|
|
82
|
+
* machinery gets NO blank at all: N consecutive folded results stack as N
|
|
83
|
+
* adjacent single rows. A folded row followed by anything that is not tool
|
|
84
|
+
* machinery keeps its blank, so the run still separates from the prose around
|
|
85
|
+
* it.
|
|
86
|
+
*/
|
|
87
|
+
export function trailingBlankAfter(
|
|
88
|
+
node: RenderNode,
|
|
89
|
+
next: RenderNode | undefined,
|
|
90
|
+
context: FoldContext,
|
|
91
|
+
): boolean {
|
|
92
|
+
// Adaptation only — RenderNode is this product's shape, so reading depth and
|
|
93
|
+
// node kind off it stays here; the rule those facts feed belongs to
|
|
94
|
+
// trailingBlankAfterRow(). No `next` at all means end of transcript: neither
|
|
95
|
+
// a branch row nor tool machinery follows, which is the plain-blank case.
|
|
96
|
+
return trailingBlankAfterRow({
|
|
97
|
+
nextIsBranchRow: next !== undefined && next.depth !== 0,
|
|
98
|
+
nextIsToolMachinery: next !== undefined && isToolRunNode(next),
|
|
99
|
+
rowRendersFolded: rendersFoldedToolRow(node, context),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -58,6 +58,7 @@ import {
|
|
|
58
58
|
isTurnCollapsed,
|
|
59
59
|
type ToolCallOutcome,
|
|
60
60
|
} from './conversation-rendering.ts';
|
|
61
|
+
import { trailingBlankAfter } from './conversation-fold.ts';
|
|
61
62
|
import {
|
|
62
63
|
buildRenderPlan,
|
|
63
64
|
computeAssistantTurns,
|
|
@@ -373,8 +374,9 @@ export class MessageLineCache {
|
|
|
373
374
|
}
|
|
374
375
|
}
|
|
375
376
|
|
|
376
|
-
|
|
377
|
-
|
|
377
|
+
// Same rule appendConversationMessages uses, from the same function, so a
|
|
378
|
+
// warm cache and a cold rebuild space rows identically.
|
|
379
|
+
const trailingBlank = trailingBlankAfter(node, plan[i + 1], renderContext);
|
|
378
380
|
const uncacheable = isRoot && node.kind === 'message' && node.absIdx === streamingPlaceholderAbsIdx;
|
|
379
381
|
const kind = isRoot ? context.messageKindRegistry.get(node.absIdx) : undefined;
|
|
380
382
|
const pendingToolKey = pendingToolKeyOf(node.message, toolCallOutcomes);
|
|
@@ -10,9 +10,9 @@ import { renderSystemMessage } from '../renderer/system-message.ts';
|
|
|
10
10
|
import { createEmptyLine, type Line, type Cell } from '@pellux/goodvibes-sdk/platform/types';
|
|
11
11
|
import { getSplashLines, type SplashOptions } from '../utils/splash-lines.ts';
|
|
12
12
|
import { interpolateColor, getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
13
|
-
import { LAYOUT, TOOL_STATUS } from '../renderer/layout.ts';
|
|
13
|
+
import { BORDERS, LAYOUT, TOOL_STATUS } from '../renderer/layout.ts';
|
|
14
14
|
import type { ConfigManager } from '@pellux/goodvibes-sdk/platform/config';
|
|
15
|
-
import {
|
|
15
|
+
import { renderConversationEventLine, renderConversationFoldedRow } from '../renderer/conversation-surface.ts';
|
|
16
16
|
import { GLYPHS } from '../renderer/ui-primitives.ts';
|
|
17
17
|
import type { BlockMeta } from './conversation-types.ts';
|
|
18
18
|
import {
|
|
@@ -21,6 +21,9 @@ import {
|
|
|
21
21
|
type ConversationRenderContext,
|
|
22
22
|
} from './conversation-render-context.ts';
|
|
23
23
|
import { renderCompactionContinuationMessage } from './conversation-compaction-render.ts';
|
|
24
|
+
// Which rows fold, and the blank separator that earns — shared verbatim with
|
|
25
|
+
// conversation-line-cache.ts so a warm cache and a cold rebuild space alike.
|
|
26
|
+
import { isToolResultFolded, trailingBlankAfter } from './conversation-fold.ts';
|
|
24
27
|
import { drawTreeRails, treeIndentCols, treeTextCol } from '@pellux/goodvibes-terminal-shell';
|
|
25
28
|
import {
|
|
26
29
|
MAX_NEST_DEPTH,
|
|
@@ -209,19 +212,24 @@ export function renderConversationAssistantMessage(
|
|
|
209
212
|
context.collapseState.set(thinkingCollapseKey, true);
|
|
210
213
|
}
|
|
211
214
|
if (isThinkingCollapsed) {
|
|
215
|
+
// One row: label plus size badge, no frame and no interior line.
|
|
216
|
+
//
|
|
217
|
+
// Deliberately NO preview, unlike a folded tool result. Reasoning text
|
|
218
|
+
// stays behind the toggle until it is asked for — the fold is the quiet
|
|
219
|
+
// state of an already-opt-in display, and previewing the first reasoning
|
|
220
|
+
// line here would disclose content the collapsed form has never shown.
|
|
212
221
|
const thinkingLineCount = message.reasoningContent.split('\n').length;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
},
|
|
223
|
-
);
|
|
224
|
-
context.history.addLines(rendered);
|
|
222
|
+
context.history.addLine(renderConversationFoldedRow(width, {
|
|
223
|
+
// The same ▌ the EXPANDED block draws (BORDERS.THINKING), so folding
|
|
224
|
+
// changes the row count without changing what the block reads as.
|
|
225
|
+
marker: BORDERS.THINKING.char,
|
|
226
|
+
markerFg: T.reasoningAccent,
|
|
227
|
+
label: 'thinking',
|
|
228
|
+
labelFg: T.reasoningAccent,
|
|
229
|
+
detailFg: '244',
|
|
230
|
+
}, [
|
|
231
|
+
{ text: ` ${GLYPHS.navigation.collapsed} ${thinkingLineCount} line${thinkingLineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
|
|
232
|
+
], ''));
|
|
225
233
|
} else {
|
|
226
234
|
const thinkingLines = renderThinkingBlock(message.reasoningContent, width);
|
|
227
235
|
context.history.addLines(thinkingLines);
|
|
@@ -495,17 +503,14 @@ export function renderConversationToolMessage(
|
|
|
495
503
|
? summarizeToolResult(message.toolName, message.content)
|
|
496
504
|
: null;
|
|
497
505
|
|
|
498
|
-
|
|
499
|
-
// one-line summary wins and the raw JSON is tucked behind the expand toggle.
|
|
500
|
-
const isShort = message.content.length <= 200 && resultSummary === null;
|
|
501
|
-
const isCollapsed = isShort
|
|
502
|
-
? false
|
|
503
|
-
: context.collapseState.has(collapseKey)
|
|
504
|
-
? context.collapseState.get(collapseKey)!
|
|
505
|
-
: true;
|
|
506
|
+
const isCollapsed = isToolResultFolded(message, context.collapseState, collapseKey);
|
|
506
507
|
|
|
508
|
+
// The fold decision IS the stored default: with nothing stored,
|
|
509
|
+
// isToolResultFolded() already resolved to exactly the value this key should
|
|
510
|
+
// hold. Recomputing "is it short?" here would be a second copy of the
|
|
511
|
+
// threshold that the policy module owns.
|
|
507
512
|
if (!context.collapseState.has(collapseKey)) {
|
|
508
|
-
context.collapseState.set(collapseKey,
|
|
513
|
+
context.collapseState.set(collapseKey, isCollapsed);
|
|
509
514
|
}
|
|
510
515
|
|
|
511
516
|
// A per-call user cancellation settles as a tool result whose content leads
|
|
@@ -529,16 +534,24 @@ export function renderConversationToolMessage(
|
|
|
529
534
|
? [{ text: ` ${message.toolName} `, fg: T.toolNameFg }]
|
|
530
535
|
: [{ text: ` ${summarizeCallId(message.callId || 'standalone')} `, fg: '244' as const, dim: true }]);
|
|
531
536
|
|
|
532
|
-
|
|
537
|
+
// A FOLDED result is exactly one row: this header, with its preview riding on
|
|
538
|
+
// the same line right after the `▸ N lines` badge (see
|
|
539
|
+
// renderConversationFoldedRow). The badge is the count — the old fold also
|
|
540
|
+
// carried a separate `[▸ N hidden]` marker, which restated it.
|
|
541
|
+
const headerTone = {
|
|
533
542
|
marker: isCancelled ? GLYPHS.status.blocked : (blockType === 'diff' ? GLYPHS.status.dualPane : GLYPHS.status.active),
|
|
534
543
|
markerFg: isCancelled ? warnTone : (blockType === 'diff' ? T.diffAccent : T.toolAccent),
|
|
535
544
|
label,
|
|
536
545
|
labelFg: isCancelled ? warnTone : (blockType === 'diff' ? T.diffAccent : T.toolAccent),
|
|
537
546
|
detailFg: '244',
|
|
538
|
-
}
|
|
547
|
+
};
|
|
548
|
+
const headerDetails = [
|
|
539
549
|
...nameSegments,
|
|
540
550
|
{ text: ` ${isCollapsed ? GLYPHS.navigation.collapsed : GLYPHS.navigation.expanded} ${lineCount} line${lineCount === 1 ? '' : 's'} `, fg: '244', dim: true },
|
|
541
|
-
]
|
|
551
|
+
];
|
|
552
|
+
const headerLine = isCollapsed
|
|
553
|
+
? renderConversationFoldedRow(width, headerTone, headerDetails, resultSummary ?? contentLines[0] ?? '', indent)
|
|
554
|
+
: renderConversationEventLine(width, headerTone, headerDetails, indent);
|
|
542
555
|
// Every line this row emits, gathered before anything is committed, so the
|
|
543
556
|
// rails can be drawn across all of them in one pass (see drawTreeRails). The
|
|
544
557
|
// result row deliberately carries NO status marker of its own: the call row
|
|
@@ -546,32 +559,7 @@ export function renderConversationToolMessage(
|
|
|
546
559
|
// and a second marker one row down only doubles it.
|
|
547
560
|
const rows: Line[] = [headerLine];
|
|
548
561
|
|
|
549
|
-
if (isCollapsed) {
|
|
550
|
-
const collapseSuffixReserve = 30;
|
|
551
|
-
const avail = Math.max(0, width - LAYOUT.LEFT_MARGIN - LAYOUT.RIGHT_MARGIN - indent - collapseSuffixReserve);
|
|
552
|
-
const hiddenCount = lineCount - 1;
|
|
553
|
-
let collapsedText: string;
|
|
554
|
-
if (resultSummary !== null) {
|
|
555
|
-
// Summary line — the header already shows "▸ N lines", so no hidden badge.
|
|
556
|
-
collapsedText = resultSummary.length > avail
|
|
557
|
-
? `${resultSummary.slice(0, Math.max(0, avail - 1))}…`
|
|
558
|
-
: resultSummary;
|
|
559
|
-
} else {
|
|
560
|
-
const preview = contentLines[0].slice(0, avail);
|
|
561
|
-
collapsedText = hiddenCount > 0
|
|
562
|
-
? `${preview}... [${GLYPHS.navigation.collapsed} ${hiddenCount} hidden]`
|
|
563
|
-
: preview;
|
|
564
|
-
}
|
|
565
|
-
const rendered = renderConversationCollapsedFragment(collapsedText, width, {
|
|
566
|
-
prefix: blockType === 'diff' ? ` ${GLYPHS.status.dualPane} ` : ` ${GLYPHS.navigation.collapsed} `,
|
|
567
|
-
prefixFg: blockType === 'diff' ? T.diffAccent : T.toolAccent,
|
|
568
|
-
text: '244',
|
|
569
|
-
bodyBg: T.collapsedBodyBg,
|
|
570
|
-
dim: true,
|
|
571
|
-
indentCols: indent,
|
|
572
|
-
});
|
|
573
|
-
for (const line of rendered) rows.push(line);
|
|
574
|
-
} else {
|
|
562
|
+
if (!isCollapsed) {
|
|
575
563
|
// Diff or plain result — either way this is exactly `expandedLines`,
|
|
576
564
|
// already computed above (and already what the header's line count is
|
|
577
565
|
// honest about), so there is nothing left to render here.
|
|
@@ -683,8 +671,9 @@ export function appendConversationMessages(
|
|
|
683
671
|
// Branch rows sit tight under their parent; the blank separator lands only
|
|
684
672
|
// after the last row of a top-level unit, which is what keeps a turn's
|
|
685
673
|
// whole subtree reading as one block instead of a run of spaced-out rows.
|
|
686
|
-
|
|
687
|
-
|
|
674
|
+
// A folded result followed by more tool machinery gets no blank at all —
|
|
675
|
+
// consecutive folded results stack as adjacent single rows.
|
|
676
|
+
if (trailingBlankAfter(node, plan[i + 1], turnContext)) {
|
|
688
677
|
turnContext.history.addLine(createEmptyLine(width));
|
|
689
678
|
}
|
|
690
679
|
}
|
|
@@ -2,7 +2,7 @@ import { type Line, createEmptyLine, createStyledCell } from '@pellux/goodvibes-
|
|
|
2
2
|
import { getDisplayWidth, wrapText } from '../utils/terminal-width.ts';
|
|
3
3
|
import { LAYOUT } from './layout.ts';
|
|
4
4
|
import { GLYPHS } from './ui-primitives.ts';
|
|
5
|
-
import { treeBranchCol, treeContentCol } from '@pellux/goodvibes-terminal-shell';
|
|
5
|
+
import { foldPreviewText, treeBranchCol, treeContentCol } from '@pellux/goodvibes-terminal-shell';
|
|
6
6
|
|
|
7
7
|
export interface ConversationSurfacePalette {
|
|
8
8
|
readonly accent: string;
|
|
@@ -173,28 +173,49 @@ export function renderConversationFragment(
|
|
|
173
173
|
return lines;
|
|
174
174
|
}
|
|
175
175
|
|
|
176
|
-
|
|
177
|
-
|
|
176
|
+
/**
|
|
177
|
+
* A COLLAPSED block, as exactly one row: the event line, with its preview
|
|
178
|
+
* riding on the same line right after the badges.
|
|
179
|
+
*
|
|
180
|
+
* Collapsed blocks used to render as a framed fragment — a `▄▄▄` cap, an
|
|
181
|
+
* interior line of text, a `▀▀▀` cap — around what was, in every case, a single
|
|
182
|
+
* line. Four rows of chrome per fold made a screen of collapsed output mostly
|
|
183
|
+
* frame. The text those rows carried moves onto the event line and the frame
|
|
184
|
+
* goes; the `▸ N lines` badge is the count, so nothing restates it.
|
|
185
|
+
*
|
|
186
|
+
* The preview is TRUNCATED to fit, never wrapped: wrapping would put the row
|
|
187
|
+
* back to two lines, which is the thing being removed. Whitespace is flattened
|
|
188
|
+
* by the caller's choice of preview text — a raw tab or newline would blow the
|
|
189
|
+
* column out.
|
|
190
|
+
*/
|
|
191
|
+
export function renderConversationFoldedRow(
|
|
178
192
|
width: number,
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
)
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
193
|
+
tone: ConversationEventTone,
|
|
194
|
+
details: readonly ConversationStatusSegment[],
|
|
195
|
+
preview: string,
|
|
196
|
+
indentCols = 0,
|
|
197
|
+
): Line {
|
|
198
|
+
// What is left of the row after the marker, the label and the badges.
|
|
199
|
+
const usedCols = (tone.label ? getDisplayWidth(` ${tone.label} `) : 0)
|
|
200
|
+
+ details.reduce((sum, segment) => sum + getDisplayWidth(segment.text), 0);
|
|
201
|
+
const availCols = width - LAYOUT.RIGHT_MARGIN - treeContentCol(Math.max(0, indentCols)) - usedCols - 1;
|
|
202
|
+
// Whether a preview renders at all, and its flattening, are the policy's call
|
|
203
|
+
// (foldPreviewText returns null for "no preview"). Only the TRUNCATION is
|
|
204
|
+
// ours: display width is a product-local rule the policy deliberately leaves
|
|
205
|
+
// to the caller — and it must truncate, never wrap.
|
|
206
|
+
const trimmed = foldPreviewText(preview, availCols);
|
|
207
|
+
if (trimmed === null) {
|
|
208
|
+
return renderConversationEventLine(width, tone, details, indentCols);
|
|
209
|
+
}
|
|
210
|
+
const text = getDisplayWidth(trimmed) > availCols
|
|
211
|
+
? `${trimmed.slice(0, Math.max(1, availCols - 1))}…`
|
|
212
|
+
: trimmed;
|
|
213
|
+
return renderConversationEventLine(
|
|
214
|
+
width,
|
|
215
|
+
tone,
|
|
216
|
+
[...details, { text: `${text} `, fg: tone.detailFg ?? '244', dim: true }],
|
|
217
|
+
indentCols,
|
|
218
|
+
);
|
|
198
219
|
}
|
|
199
220
|
|
|
200
221
|
export function renderConversationStatusLine(
|
package/src/version.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { join } from 'node:path';
|
|
|
6
6
|
// The prebuild script updates the fallback value before compilation.
|
|
7
7
|
// Uses import.meta.dir (Bun) to locate package.json relative to this file,
|
|
8
8
|
// which is correct regardless of the process working directory.
|
|
9
|
-
let _version = '2.0.
|
|
9
|
+
let _version = '2.0.4';
|
|
10
10
|
try {
|
|
11
11
|
const pkg = JSON.parse(readFileSync(join(import.meta.dir, '..', 'package.json'), 'utf-8'));
|
|
12
12
|
// Only trust a version read from OUR OWN package.json. A Bun single-file
|