@pellux/goodvibes-tui 2.0.3 → 2.0.5

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 CHANGED
@@ -4,6 +4,37 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [2.0.5] - 2026-08-02
8
+
9
+ ### Changes
10
+
11
+ - Fixed: a corrupt watcher snapshot no longer takes down the process that
12
+ reads it. Snapshots save atomically, and a torn file — the live case was a
13
+ host freeze leaving valid JSON followed by NUL bytes — is quarantined with
14
+ a receipt while watcher state rebuilds from live registrations (platform
15
+ runtime 2.0.4).
16
+ - Fixed: refusing an unknown owner-profile field id names the valid ids
17
+ instead of citing a documentation section.
18
+
19
+ ## [2.0.4] - 2026-08-01
20
+
21
+ ### Changes
22
+
23
+ - **Collapsed output is compact.** A folded block — tool result, thinking
24
+ block, or compaction handoff — now renders as exactly one row: its header,
25
+ with the `▸ N lines` badge as the only count and the head of the content
26
+ riding the same line (thinking folds keep their content behind the toggle
27
+ by design). The frame rows, the interior preview row with its second
28
+ hidden-count marker, and the blank rows between consecutive folded results
29
+ are gone: three collapsed tool results that took nineteen rows now take
30
+ ten. Expanded rendering is unchanged. The fold decisions — threshold,
31
+ default, separators, preview rule — now come from the shared platform
32
+ policy in `@pellux/goodvibes-terminal-shell` (2.0.3), the same module the
33
+ Agent reads, so the two renderers cannot drift apart again.
34
+ - Fixed: a client that inherited the daemon's bind host is no longer refused
35
+ as "insecure PUBLIC transport" when that host is a wildcard (platform
36
+ runtime 2.0.3).
37
+
7
38
  ## [2.0.3] - 2026-08-01
8
39
 
9
40
  ### Changes
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [![CI](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml/badge.svg)](https://github.com/mgd34msu/goodvibes-tui/actions/workflows/ci.yml)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
- [![Version](https://img.shields.io/badge/version-2.0.3-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-2.0.5-blue.svg)](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
 
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "2.0.2"
6
+ "version": "2.0.4"
7
7
  },
8
8
  "auth": {
9
9
  "modes": [
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pellux/goodvibes-tui",
3
- "version": "2.0.3",
3
+ "version": "2.0.5",
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.2",
99
- "@pellux/goodvibes-terminal-shell": "2.0.2",
98
+ "@pellux/goodvibes-sdk": "2.0.4",
99
+ "@pellux/goodvibes-terminal-shell": "2.0.4",
100
100
  "bash-language-server": "^5.6.0",
101
101
  "fuse.js": "^7.1.0",
102
- "@pellux/goodvibes-daemon": "1.28.4",
102
+ "@pellux/goodvibes-daemon": "1.28.6",
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.3",
128
- "@pellux/goodvibes-tui-linux-arm64": "2.0.3",
129
- "@pellux/goodvibes-tui-darwin-x64": "2.0.3",
130
- "@pellux/goodvibes-tui-darwin-arm64": "2.0.3"
127
+ "@pellux/goodvibes-tui-linux-x64": "2.0.5",
128
+ "@pellux/goodvibes-tui-linux-arm64": "2.0.5",
129
+ "@pellux/goodvibes-tui-darwin-x64": "2.0.5",
130
+ "@pellux/goodvibes-tui-darwin-arm64": "2.0.5"
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 { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
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
- context.history.addLine(renderConversationEventLine(width, {
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
- const rendered = renderConversationCollapsedFragment(
64
- 'compacted-context handoff (re-injected instructions + session summary)',
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
- prefix: ` ${GLYPHS.navigation.collapsed} `,
68
- prefixFg: T.toolAccent,
69
- text: '244',
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
- const next = plan[i + 1];
377
- const trailingBlank = !next || next.depth === 0;
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 { renderConversationCollapsedFragment, renderConversationEventLine } from '../renderer/conversation-surface.ts';
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
- const rendered = renderConversationCollapsedFragment(
214
- `thinking · ${thinkingLineCount} line${thinkingLineCount === 1 ? '' : 's'}`,
215
- width,
216
- {
217
- prefix: ' ▌ ',
218
- prefixFg: T.reasoningAccent,
219
- text: '244',
220
- bodyBg: T.collapsedBodyBg,
221
- dim: true,
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
- // A summarizable result is kept collapsed-by-default even when short, so the
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, isShort ? false : true);
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
- const headerLine = renderConversationEventLine(width, {
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
- ], indent);
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
- const next = plan[i + 1];
687
- if (!next || next.depth === 0) {
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
- export function renderConversationCollapsedFragment(
177
- content: string,
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
- options: {
180
- readonly prefix?: string;
181
- readonly prefixFg?: string;
182
- readonly text?: string;
183
- readonly bodyBg?: string;
184
- readonly dim?: boolean;
185
- readonly italic?: boolean;
186
- /** Tree-branch indent, in columns (see conversation-tree.ts). */
187
- readonly indentCols?: number;
188
- } = {},
189
- ): Line[] {
190
- return renderConversationFragment(content, width, {
191
- prefix: options.prefix ?? ` ${GLYPHS.navigation.selected} `,
192
- prefixFg: options.prefixFg ?? '#38bdf8',
193
- text: options.text ?? '244',
194
- bodyBg: options.bodyBg ?? '#1a1a1a',
195
- dim: options.dim ?? true,
196
- italic: options.italic ?? false,
197
- }, options.indentCols ?? 0);
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.3';
9
+ let _version = '2.0.5';
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