@pellux/goodvibes-tui 1.20.0 → 1.20.1

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,13 @@ All notable changes to GoodVibes TUI.
4
4
 
5
5
  ---
6
6
 
7
+ ## [1.20.1] - 2026-07-25
8
+
9
+ ### Changes
10
+
11
+ - fd3b3037 fix: recovery offers and removals behave; active-model surfaces agree; failover is visible and temporary
12
+ - 08237438 docs: replace README screenshots with true terminal captures
13
+
7
14
  ## [1.20.0] - 2026-07-24
8
15
 
9
16
  ### Changes
package/README.md CHANGED
@@ -2,11 +2,11 @@
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-1.20.0-blue.svg)](https://github.com/mgd34msu/goodvibes-tui)
5
+ [![Version](https://img.shields.io/badge/version-1.20.1-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
 
9
- <img src="docs/assets/splash.png" alt="GoodVibes starting up in a terminal: a glitch-art GOODVIBES wordmark in cyan-to-magenta gradient, the version and tagline beneath it, the active model and tool count, the working directory, and a hint line reading Ctrl+P panels / ? help / F2 fleet. A status footer shows mode, token usage, context usage, and the daemon and web listener addresses." width="900">
9
+ <img src="docs/assets/splash.png" alt="GoodVibes starting up in a terminal: a glitch-art GOODVIBES wordmark in cyan-to-magenta gradient, the version and tagline beneath it, the active model and tool count, the working directory, a line offering to resume the last session, and a hint line reading Ctrl+P panels / ? help / F2 fleet. A status footer shows mode, token usage, context usage, and the daemon and web listener addresses." width="900">
10
10
 
11
11
  ---
12
12
 
@@ -45,7 +45,7 @@ Deeper install notes — the installer's environment variables, uninstall, daemo
45
45
 
46
46
  ### The conversation loop
47
47
 
48
- <img src="docs/assets/conversation.png" alt="A conversation turn in the terminal. The user asks the model to read two source files and explain the project. Below it, an assistant block lists two read tool calls with green checkmarks, followed by a collapsed group labelled tool results, 2 tools, 2 lines. A second assistant block makes one more read call. The final answer is plain prose with an identifier syntax-highlighted in yellow. The footer reads Token Usage: Input 41.3k, Output 337, Cache Read 0, Cache Write 0, Total 41.6k, approximately 0.129 dollars, above a green context usage bar at 5 percent." width="900">
48
+ <img src="docs/assets/conversation.png" alt="Two conversation turns in the terminal. The tail of an earlier answer describes the project as a minimal ISS pass tracker whose calculation is still a stub. Below it the user asks the model to grep for nextPass and list every file that references it; an assistant block shows one find tool call with a green checkmark, and its result is expanded as syntax-highlighted JSON with line numbers listing the two matching file paths and a count of 2. The final answer names src/passes.ts and src/index.ts as a bulleted list. The footer reads Token Usage: Input em dash, Output 264, Cache Read 0, Cache Write 0, Total 264, approximately 0.0040 dollars, above a green context usage bar at 4 percent." width="900">
49
49
 
50
50
  Tool calls stream inline as the model makes them, then their results fold into a collapsible group so a long working session stays readable — expand any group to see what actually came back. Assistant messages render markdown, syntax-highlighted code, and inline diffs, and any block can be collapsed, bookmarked, copied, or saved to a file.
51
51
 
@@ -57,13 +57,13 @@ The footer keeps an honest running total: fresh input tokens separated from cach
57
57
 
58
58
  The first time you open a directory, GoodVibes asks how much it is allowed to do there. Restricted is read-only: the model can look around, but writes and commands are refused until you trust the workspace.
59
59
 
60
- <img src="docs/assets/permission-prompt.png" alt="An execution approval prompt. A header reads EXECUTE, Shell Execution Approval. Fields list the requesting session, the tool exec, the full command, the working directory, a Risk line reading HIGH write in red, the surface and radius, a summary, the effects: process execution, filesystem mutation, possible network access, and a reviewer checklist. Below, four numbered remember options scope an approval to this exact command, to every mkdir command, to every exec call in this project, or to the rest of the session in memory only. The action line reads: Y Allow once, 1-4 Allow and remember, N Deny, or type a reason to deny." width="900">
60
+ <img src="docs/assets/permission-prompt.png" alt="An execution approval prompt. A header reads EXECUTE, Shell Execution Approval, one more waiting. Fields list the requesting session, the tool exec, the full command mkdir -p build, the working directory, a Risk line reading HIGH write in red, the surface and radius, a summary, the decision class, the effects: process execution, filesystem mutation, possible network access, a reviewer checklist, and the raw tool arguments. Below, four numbered remember options scope an approval to this exact command, to every mkdir command, to every exec call in this project, or to the rest of the session in memory only. The action line reads: Y Allow once, 1-4 Allow and remember, N Deny, or type a reason to deny." width="900">
61
61
 
62
62
  After that, the default permission mode is `prompt`: writes, edits, shell commands, network fetches, agent spawns, and MCP calls each stop and ask. The prompt shows what will run, in which directory, its assessed risk, and what it can affect. You can allow it once, deny it (optionally typing a reason the model sees), or remember the decision at whichever scope fits — this exact command, this command shape, this tool for the whole project, or just for the rest of the session. Four other modes are available when prompting is not what you want: `accept-edits` auto-approves file writes and edits while exec and the other risky classes still ask, `plan` allows read-only tools and refuses every mutating or exec call, `allow-all` approves everything, and `custom` takes per-tool `allow` / `prompt` / `deny` overrides. `Shift+Tab` cycles the four session postures — normal, accept-edits, plan, auto — and `/plan` toggles plan mode directly.
63
63
 
64
64
  ### Models and providers
65
65
 
66
- <img src="docs/assets/model-picker.png" alt="A fullscreen model workspace. A left column lists routing targets: Main Chat set, Helper Model off, Tool LLM off, TTS LLM inherit, and Embeddings set. The right pane shows the selected target with its current model and a filter row for search, price, capability, grouping, and availability. Below, a table of models lists model key, display name, provider, context window, tier, and capability flags across 2017 catalogued entries, with a footer of list shortcuts." width="900">
66
+ <img src="docs/assets/model-picker.png" alt="A fullscreen model workspace. A left column lists routing targets: Main Chat set, Helper Model off, Tool LLM off, TTS LLM inherit, and Embeddings set. The right pane shows the selected target with its current model and a filter row for search, price, capability, grouping, and availability. Below, a table of models lists model key, display name, provider, context window, tier, and capability flags across 2030 catalogued entries, with a footer of list shortcuts." width="900">
67
67
 
68
68
  Models come from a live catalog, so the picker lists far more than a hardcoded set, filterable by search, price, capability, and availability. Separate roles route independently: your main chat model, a cheaper helper model for grunt work, a tool model, a TTS model, and an embeddings model can each point somewhere different.
69
69
 
@@ -71,13 +71,13 @@ The `synthetic` provider groups the same model across every backend that serves
71
71
 
72
72
  ### The Fleet control room
73
73
 
74
- <img src="docs/assets/fleet-panel.png" alt="The Fleet panel open beside the conversation. It lists background work as rows with a kind, a name, elapsed time, token count, and cost — a CI watch poller and a runtime health watcher at 59 seconds each, and a code indexer. The detail region below shows the selected watcher's id, state idle, elapsed time, model, tokens, cost, a headline reading CI watch poller, healthy, an activity phase, and an approvals line. The action hints read j/k navigate, Enter attach, K kill, A archive finish." width="900">
74
+ <img src="docs/assets/fleet-panel.png" alt="The Fleet panel open beside the conversation. It lists background work as rows with a kind, a name, elapsed time, token count, and cost — a CI watch poller and a runtime heartbeat watcher at 4 minutes 23 seconds each, and an idle code indexer. The detail region below shows the selected watcher's id, state idle, elapsed time, model, tokens, cost, a headline reading CI watch poller, healthy, an activity phase, and an approvals line. The action hints read j/k navigate, Enter attach, K kill, A archive finished, v archived, n host agent." width="900">
75
75
 
76
76
  Work that is not conversation goes into panels instead of scrolling past in the transcript. Fleet — reached with `F2` — is the live control room for agents, workstreams, watchers, and scheduled jobs: what is running, for how long, at what token cost, and what it is waiting on. You can attach to a running agent to watch or steer it, detach again without killing it, pause and resume, and archive finished work. Git, diff, cost, token, and local-auth panels sit alongside it; heavier operator surfaces open as review modals.
77
77
 
78
78
  ### Everything else is a command
79
79
 
80
- <img src="docs/assets/help-overlay.png" alt="A searchable help overlay titled Help, Commands, with a search field and results grouped by category. Under Provider Accounts is the /accounts command with review, panel, show, routes, and repair subcommands and a one-line description. Under Fleet is /agents, described as hosting third-party coding agents as fleet rows over ACP. A line reads 146 below, and the hints read Up/Down Navigate, Enter Run, / Search, Esc Close." width="820">
80
+ <img src="docs/assets/help-overlay.png" alt="A searchable help overlay titled Help, Commands, with a search field and results grouped by category. Under Provider Accounts is the /accounts command with review, panel, show, routes, and repair subcommands and a one-line description. Under Fleet is /agents, described as hosting third-party coding agents as fleet rows over ACP. Under Experience is /approval, for reviewing action-specific approval classes. A line reads 147 below, and the hints read Up/Down Navigate, Enter Run, / Search, Esc Close." width="820">
81
81
 
82
82
  Press `?` for a searchable, categorized list of every slash command with its arguments and description. The same list is generated into [docs/commands-reference.md](docs/commands-reference.md).
83
83
 
@@ -3,7 +3,7 @@
3
3
  "product": {
4
4
  "id": "goodvibes",
5
5
  "surface": "operator",
6
- "version": "1.12.0"
6
+ "version": "1.12.1"
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": "1.20.0",
3
+ "version": "1.20.1",
4
4
  "description": "Terminal-native GoodVibes product for coding, operations, automation, knowledge, channels, and daemon-backed control-plane workflows.",
5
5
  "type": "module",
6
6
  "main": "src/main.ts",
@@ -104,8 +104,8 @@
104
104
  "@anthropic-ai/vertex-sdk": "^0.16.0",
105
105
  "@ast-grep/napi": "^0.42.0",
106
106
  "@aws/bedrock-token-generator": "^1.1.0",
107
- "@pellux/goodvibes-sdk": "1.12.0",
108
- "@pellux/goodvibes-terminal-shell": "1.12.0",
107
+ "@pellux/goodvibes-sdk": "1.12.1",
108
+ "@pellux/goodvibes-terminal-shell": "1.12.1",
109
109
  "bash-language-server": "^5.6.0",
110
110
  "fuse.js": "^7.1.0",
111
111
  "graphql": "^16.13.2",
@@ -131,10 +131,10 @@
131
131
  "typescript": "^5.9.3"
132
132
  },
133
133
  "optionalDependencies": {
134
- "@pellux/goodvibes-tui-linux-x64": "1.20.0",
135
- "@pellux/goodvibes-tui-linux-arm64": "1.20.0",
136
- "@pellux/goodvibes-tui-darwin-x64": "1.20.0",
137
- "@pellux/goodvibes-tui-darwin-arm64": "1.20.0"
134
+ "@pellux/goodvibes-tui-linux-x64": "1.20.1",
135
+ "@pellux/goodvibes-tui-linux-arm64": "1.20.1",
136
+ "@pellux/goodvibes-tui-darwin-x64": "1.20.1",
137
+ "@pellux/goodvibes-tui-darwin-arm64": "1.20.1"
138
138
  },
139
139
  "trustedDependencies": [],
140
140
  "overrides": {
@@ -12,7 +12,7 @@ import type { ConfigKey } from '../config/index.ts';
12
12
  import type { CliCommandRuntime } from './types.ts';
13
13
  import type { CliCommandOutput } from './types.ts';
14
14
  import { getPackageVersion } from './help.ts';
15
- import { classifyProviderSetup } from './provider-classification.ts';
15
+ import { classifyProviderSetup } from '../providers/provider-classification.ts';
16
16
  import { buildCliServicePosture } from './service-posture.ts';
17
17
  import { REDACTED_VALUE, collectSensitiveConfigValues, isRedactedValue, redactConfig, redactSerializedSecrets } from './redaction.ts';
18
18
  import { summarizeError } from '@pellux/goodvibes-sdk/platform/utils';
@@ -13,7 +13,7 @@ import { beginOpenAICodexLogin, exchangeOpenAICodexCode } from '@pellux/goodvibe
13
13
  import { inspectProviderAuth } from '@/runtime/index.ts';
14
14
  import type { CliCommandRuntime, GoodVibesCliParseResult } from './types.ts';
15
15
  import { formatProviderAuthRoute, summarizeProviderAuthRoutes } from './provider-auth-routes.ts';
16
- import { classifyProviderSetup } from './provider-classification.ts';
16
+ import { classifyProviderSetup } from '../providers/provider-classification.ts';
17
17
  import { resolveRuntimeEndpointBinding } from './endpoints.ts';
18
18
  import { applyRuntimeEndpointFlagOverrides } from './config-overrides.ts';
19
19
  import type { RuntimeEndpointId } from './endpoints.ts';
@@ -0,0 +1,153 @@
1
+ /**
2
+ * active-model-identity.ts — the single answer to "which backend is serving
3
+ * this session right now, and is that what the user chose?"
4
+ *
5
+ * Two shell surfaces used to answer that question independently and could
6
+ * therefore contradict each other:
7
+ * - the top-right header read `providerRegistry.getCurrentModel()` live
8
+ * every frame, so it followed an automatic failover switch immediately;
9
+ * - the footer read `runtime.model` / `runtime.provider`, which only
10
+ * bootstrap and explicit user model switches ever wrote, so it kept
11
+ * naming the configured backend after failover had moved serving (and
12
+ * billing) somewhere else.
13
+ *
14
+ * Both surfaces now resolve through resolveActiveModelDisplay() so they are
15
+ * computed from one comparison of serving-vs-configured. Divergence is
16
+ * detected structurally — by comparing the live registry key against the
17
+ * configured selection — not by trusting a flag, so ANY path that moves the
18
+ * registry off the user's choice is surfaced, not just the failover path that
19
+ * sets the record below.
20
+ *
21
+ * The failover record adds the REASON for a divergence ("failover from …");
22
+ * it never decides whether one exists.
23
+ */
24
+
25
+ /**
26
+ * The live per-turn record of an automatic provider failover.
27
+ *
28
+ * `configuredRegistryKey` is sticky across repeated failovers within one turn
29
+ * (a second hop still reports divergence from what the USER chose, not from
30
+ * the first fallback). `servingRegistryKey` tracks the most recent switch.
31
+ */
32
+ export interface FailoverTurnRecord {
33
+ /** The user's configured selection (`provider.model`) when failover began. */
34
+ readonly configuredRegistryKey: string;
35
+ /** The registry key failover switched serving to. */
36
+ readonly servingRegistryKey: string;
37
+ }
38
+
39
+ /**
40
+ * Mutable holder for the failover record, owned by the shell composition root
41
+ * (main.ts) and shared between the stream-event wiring that sets it and the
42
+ * render frame that reads it.
43
+ */
44
+ export interface FailoverTurnState {
45
+ /** The active record, or null when no failover is in effect. */
46
+ current(): FailoverTurnRecord | null;
47
+ /**
48
+ * Record a failover switch. On a second switch within the same turn the
49
+ * originally-configured key is preserved — divergence is always reported
50
+ * against the user's own selection.
51
+ */
52
+ begin(record: FailoverTurnRecord): void;
53
+ /** Drop the record — called only after serving is actually back on the configured selection. */
54
+ clear(): void;
55
+ }
56
+
57
+ /** Create an empty FailoverTurnState. */
58
+ export function createFailoverTurnState(): FailoverTurnState {
59
+ let active: FailoverTurnRecord | null = null;
60
+ return {
61
+ current: () => active,
62
+ begin(record: FailoverTurnRecord): void {
63
+ active = active === null
64
+ ? record
65
+ : { configuredRegistryKey: active.configuredRegistryKey, servingRegistryKey: record.servingRegistryKey };
66
+ },
67
+ clear(): void { active = null; },
68
+ };
69
+ }
70
+
71
+ /** What the live provider registry reports as serving this frame. */
72
+ export interface ServingModelIdentity {
73
+ readonly id: string;
74
+ readonly provider: string;
75
+ readonly registryKey?: string | undefined;
76
+ }
77
+
78
+ export interface ActiveModelInputs {
79
+ /** Live registry answer — who is actually serving right now. */
80
+ readonly serving: ServingModelIdentity;
81
+ /** The user's configured selection as a registry key (config `provider.model`). */
82
+ readonly configuredRegistryKey: string | undefined;
83
+ /** The configured selection's footer label (session `runtime.model`). */
84
+ readonly configuredLabel: string | undefined;
85
+ /** The configured selection's provider id (session `runtime.provider`). */
86
+ readonly configuredProvider: string | undefined;
87
+ /** The live failover record, when one is in effect. */
88
+ readonly failover: FailoverTurnRecord | null;
89
+ }
90
+
91
+ export interface ActiveModelDisplay {
92
+ /** Model text for the header's right-hand segment. */
93
+ readonly headerModel: string;
94
+ /** Provider text for the header's right-hand segment. */
95
+ readonly headerProvider: string;
96
+ /** Model text for the footer's context-info segment. */
97
+ readonly footerModel: string;
98
+ /** Provider text for the footer's context-info segment. */
99
+ readonly footerProvider: string;
100
+ /**
101
+ * Divergence marker naming the configured selection, e.g.
102
+ * `failover from abacusai:route-llm`. Empty string when serving is the
103
+ * configured selection — in that case both surfaces render exactly as they
104
+ * did before this resolver existed.
105
+ */
106
+ readonly divergenceNote: string;
107
+ /** True when the serving backend is not the user's configured selection. */
108
+ readonly diverged: boolean;
109
+ }
110
+
111
+ /**
112
+ * Decide what the header and footer say about the active model this frame.
113
+ *
114
+ * Divergence requires BOTH a known live registry key and a known configured
115
+ * selection; if either is missing the resolver reports no divergence rather
116
+ * than guessing, because a false "failover" marker would be its own lie. The
117
+ * configured selection is matched against the serving registry key AND the
118
+ * bare serving model id, so a config value stored as a bare model id (rather
119
+ * than the `provider:model` registry key) does not read as permanent divergence.
120
+ */
121
+ export function resolveActiveModelDisplay(input: ActiveModelInputs): ActiveModelDisplay {
122
+ const servingKey = input.serving.registryKey;
123
+ const configuredKey = input.configuredRegistryKey;
124
+ const diverged = Boolean(
125
+ servingKey && configuredKey && configuredKey !== servingKey && configuredKey !== input.serving.id,
126
+ );
127
+ if (!diverged) {
128
+ return {
129
+ headerModel: input.serving.id,
130
+ headerProvider: input.serving.provider,
131
+ // Footer keeps its own established label form (runtime.model) when there
132
+ // is nothing to correct, so the unchanged case renders byte-identically.
133
+ footerModel: input.configuredLabel ?? input.serving.id,
134
+ footerProvider: input.configuredProvider ?? input.serving.provider,
135
+ divergenceNote: '',
136
+ diverged: false,
137
+ };
138
+ }
139
+ // Serving != configured. Both surfaces now name the SERVING backend, plus a
140
+ // marker naming the configured selection it departed from. The failover
141
+ // record supplies the reason only when it matches the backend actually
142
+ // serving; any other divergence is described without claiming a cause.
143
+ const causedByFailover = input.failover !== null && input.failover.servingRegistryKey === servingKey;
144
+ const reason = causedByFailover ? 'failover from' : 'not the configured';
145
+ return {
146
+ headerModel: input.serving.id,
147
+ headerProvider: input.serving.provider,
148
+ footerModel: input.serving.id,
149
+ footerProvider: input.serving.provider,
150
+ divergenceNote: `${reason} ${configuredKey}`,
151
+ diverged: true,
152
+ };
153
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * conversation-user-receipts.ts — tracks which system-role message indices
3
+ * are a direct receipt for an explicit user action (e.g. answering the
4
+ * startup recovery modal's Resume/Keep/Remove choice), as opposed to ambient
5
+ * boot chatter (provider registration, the resume notice, etc.). Kept in its
6
+ * own file so ConversationManager (core/conversation.ts) — already near its
7
+ * line-count gate — doesn't have to carry the bookkeeping inline.
8
+ *
9
+ * ConversationManager.rebuildHistory()'s splash-vs-transcript check consults
10
+ * this via ConversationManager.addUserActionReceipt(): a receipt the user is
11
+ * actively waiting on must displace the splash the way a user message does,
12
+ * while quiet startup plumbing keeps rendering underneath it. Indices mirror
13
+ * messageKindRegistry's own lifecycle — cleared on resetAll(), purged for
14
+ * indices an undo() frees for reuse, deleted when a later add overwrites the
15
+ * same recycled index with a non-receipt message.
16
+ */
17
+ export class UserReceiptIndices {
18
+ private readonly indices = new Set<number>();
19
+
20
+ add(index: number): void {
21
+ this.indices.add(index);
22
+ }
23
+
24
+ delete(index: number): void {
25
+ this.indices.delete(index);
26
+ }
27
+
28
+ has(index: number): boolean {
29
+ return this.indices.has(index);
30
+ }
31
+
32
+ clear(): void {
33
+ this.indices.clear();
34
+ }
35
+
36
+ /** Drop every tracked index >= minIndex — mirrors undo()'s tail-splice. */
37
+ purgeFrom(minIndex: number): void {
38
+ for (const key of this.indices) {
39
+ if (key >= minIndex) this.indices.delete(key);
40
+ }
41
+ }
42
+ }
@@ -14,6 +14,7 @@ import {
14
14
  } from '@pellux/goodvibes-sdk/platform/core';
15
15
  import type { BlockMeta } from './conversation-types.ts';
16
16
  import { MessageLineCache } from './conversation-line-cache.ts';
17
+ import { UserReceiptIndices } from './conversation-user-receipts.ts';
17
18
  import {
18
19
  addConversationSplashScreen,
19
20
  conversationTextToLines,
@@ -139,6 +140,8 @@ export class ConversationManager extends SdkConversationManager {
139
140
  private errorLineRegistry: number[] = [];
140
141
  /** Maps message index → SystemMessageKind for system-role messages. */
141
142
  private messageKindRegistry: Map<number, SystemMessageKind> = new Map();
143
+ /** See conversation-user-receipts.ts. */
144
+ private userReceiptIndices = new UserReceiptIndices();
142
145
  /** Streaming block start line in history buffer (for incremental streaming update). */
143
146
  private streamingStartLine = -1;
144
147
  /**
@@ -221,25 +224,21 @@ export class ConversationManager extends SdkConversationManager {
221
224
  // stale kind (e.g. 'operational') and silently mis-classify the new message.
222
225
  const nextIndex = this.getMessageSnapshot().length;
223
226
  this.messageKindRegistry.delete(nextIndex);
227
+ this.userReceiptIndices.delete(nextIndex);
224
228
  super.addSystemMessage(content);
225
229
  this.markDirty();
226
230
  }
227
231
 
228
232
  /**
229
- * addTypedSystemMessage - Add a system message with an explicit kind tag.
230
- * The kind is stored in messageKindRegistry (keyed by the message index that
231
- * will be assigned after the push) so that renderConversationSystemMessage
232
- * can use it instead of text-based pattern matching.
233
- *
234
- * Called by SystemMessageRouter.routeTypedSystemMessage when routing to the
235
- * conversation surface. Falls back to addSystemMessage for callers that do
236
- * not have kind information.
233
+ * addTypedSystemMessage - System message with an explicit kind tag, stored
234
+ * in messageKindRegistry. `isUserReceipt` (conversation-user-receipts.ts)
235
+ * additionally marks it for rebuildHistory()'s splash check — only for a
236
+ * direct receipt to an explicit user action, never ambient boot chatter.
237
237
  */
238
- public addTypedSystemMessage(content: string, kind: SystemMessageKind): void {
239
- // getMessageSnapshot().length is the index this message will receive after
240
- // addSystemMessage appends it to the messages array.
238
+ public addTypedSystemMessage(content: string, kind: SystemMessageKind, opts?: { isUserReceipt?: boolean }): void {
241
239
  const nextIndex = this.getMessageSnapshot().length;
242
240
  this.messageKindRegistry.set(nextIndex, kind);
241
+ if (opts?.isUserReceipt) this.userReceiptIndices.add(nextIndex); else this.userReceiptIndices.delete(nextIndex);
243
242
  super.addSystemMessage(content);
244
243
  this.markDirty();
245
244
  }
@@ -256,6 +255,7 @@ export class ConversationManager extends SdkConversationManager {
256
255
  for (const key of this.messageKindRegistry.keys()) {
257
256
  if (key >= postUndoCount) this.messageKindRegistry.delete(key);
258
257
  }
258
+ this.userReceiptIndices.purgeFrom(postUndoCount);
259
259
  this.markDirty();
260
260
  }
261
261
  return result;
@@ -357,6 +357,7 @@ export class ConversationManager extends SdkConversationManager {
357
357
  this.messageLineRegistry = [];
358
358
  this.errorLineRegistry = [];
359
359
  this.messageKindRegistry = new Map();
360
+ this.userReceiptIndices.clear();
360
361
  this.streamingStartLine = -1;
361
362
  this._displayFromMessageIndex = 0; // full reset — show everything on next render
362
363
  }
@@ -478,12 +479,14 @@ export class ConversationManager extends SdkConversationManager {
478
479
  const displayStart = this._displayFromMessageIndex;
479
480
  const visibleSnapshot = displayStart > 0 ? renderSnapshot.slice(displayStart) : renderSnapshot;
480
481
 
481
- // Tool messages ARE rendered (as collapsed blocks); this filter is only
482
- // for determining whether to show the splash screen (tool-only messages
483
- // don't count as visible conversation content for splash purposes).
484
- const displayMessages = visibleSnapshot.filter(
485
- (m) => m.role !== 'tool' && m.role !== 'system',
486
- );
482
+ // Tool/system messages aren't visible splash-purposes content, except a
483
+ // system message in userReceiptIndices. visibleSnapshot mirrors the
484
+ // snapshot 1:1 (only front-sliced), so index i is absolute displayStart+i.
485
+ const displayMessages = visibleSnapshot.filter((m, i) => {
486
+ if (m.role === 'tool') return false;
487
+ if (m.role === 'system') return this.userReceiptIndices.has(displayStart + i);
488
+ return true;
489
+ });
487
490
 
488
491
  if (displayMessages.length === 0 && displayStart === 0 && !this.suppressSplash) {
489
492
  this.addSplashScreen(width);