@miphamai/cli 0.11.0 → 0.13.0

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.
@@ -20,6 +20,17 @@ export class PermissionSystem {
20
20
  private legacyDefaultFallback: PermissionLevel | null = null
21
21
  private mode: PermissionMode = 'default'
22
22
 
23
+ // ── Permission cache (P2) ──
24
+ /** Short-lived cache: toolName+input → permissionLevel. Invalidated on rule/mode change. */
25
+ private checkCache = new Map<string, PermissionLevel>()
26
+ private cacheMode: PermissionMode | null = null
27
+
28
+ /** Invalidate the permission cache (called on any rule/mode change). */
29
+ private invalidateCache(): void {
30
+ this.checkCache.clear()
31
+ this.cacheMode = null
32
+ }
33
+
23
34
  constructor(modeOrLevel: PermissionLevel = 'default') {
24
35
  if (VALID_MODES.has(modeOrLevel)) {
25
36
  this.mode = modeOrLevel as PermissionMode
@@ -34,6 +45,7 @@ export class PermissionSystem {
34
45
 
35
46
  setMode(mode: PermissionMode): void {
36
47
  this.mode = mode
48
+ this.invalidateCache()
37
49
  }
38
50
 
39
51
  getMode(): PermissionMode {
@@ -75,6 +87,8 @@ export class PermissionSystem {
75
87
  for (const rule of config.deny) {
76
88
  this.denyRules.push(compileRule(rule, 'deny'))
77
89
  }
90
+
91
+ this.invalidateCache()
78
92
  }
79
93
 
80
94
  // ── Permission check ──
@@ -91,37 +105,74 @@ export class PermissionSystem {
91
105
  * 8. System default → 'ask'
92
106
  */
93
107
  check(tool: ToolDefinition, input: Record<string, unknown>): PermissionLevel {
108
+ // ── Cache lookup (P2): reuse decision for same tool+mode+input ──
109
+ const cacheKey = tool.name + '|' + JSON.stringify(input, Object.keys(input).sort())
110
+ if (this.cacheMode === this.mode) {
111
+ const cached = this.checkCache.get(cacheKey)
112
+ if (cached !== undefined) return cached
113
+ } else {
114
+ // Mode changed — invalidate entire cache
115
+ this.checkCache.clear()
116
+ this.cacheMode = this.mode
117
+ }
118
+
94
119
  // 1. Check deny rules (always win)
95
120
  for (const rule of this.denyRules) {
96
- if (this.ruleMatches(rule, tool, input)) return 'ask'
121
+ if (this.ruleMatches(rule, tool, input)) {
122
+ const result: PermissionLevel = 'ask'
123
+ this.checkCache.set(cacheKey, result)
124
+ return result
125
+ }
97
126
  }
98
127
 
99
128
  // 2. Check ask rules
100
129
  for (const rule of this.askRules) {
101
- if (this.ruleMatches(rule, tool, input)) return 'ask'
130
+ if (this.ruleMatches(rule, tool, input)) {
131
+ const result: PermissionLevel = 'ask'
132
+ this.checkCache.set(cacheKey, result)
133
+ return result
134
+ }
102
135
  }
103
136
 
104
137
  // 3. Check allow rules
105
138
  for (const rule of this.allowRules) {
106
- if (this.ruleMatches(rule, tool, input)) return 'bypass'
139
+ if (this.ruleMatches(rule, tool, input)) {
140
+ const result: PermissionLevel = 'bypass'
141
+ this.checkCache.set(cacheKey, result)
142
+ return result
143
+ }
107
144
  }
108
145
 
109
146
  // 4. Legacy exact-name rules (backward compat)
110
147
  const legacyLevel = this.legacyRules.get(tool.name)
111
- if (legacyLevel !== undefined) return legacyLevel
148
+ if (legacyLevel !== undefined) {
149
+ this.checkCache.set(cacheKey, legacyLevel)
150
+ return legacyLevel
151
+ }
112
152
 
113
153
  // 5. Mode baseline
114
154
  const baseline = this.modeBaseline(tool)
115
- if (baseline !== 'mode-baseline') return baseline
155
+ if (baseline !== 'mode-baseline') {
156
+ this.checkCache.set(cacheKey, baseline)
157
+ return baseline
158
+ }
116
159
 
117
160
  // 6. Tool's own permission level (backward compat fallback)
118
- if (tool.permission) return tool.permission
161
+ if (tool.permission) {
162
+ this.checkCache.set(cacheKey, tool.permission)
163
+ return tool.permission
164
+ }
119
165
 
120
166
  // 7. Legacy constructor fallback
121
- if (this.legacyDefaultFallback) return this.legacyDefaultFallback
167
+ if (this.legacyDefaultFallback) {
168
+ this.checkCache.set(cacheKey, this.legacyDefaultFallback)
169
+ return this.legacyDefaultFallback
170
+ }
122
171
 
123
172
  // 8. System default
124
- return 'ask'
173
+ const result: PermissionLevel = 'ask'
174
+ this.checkCache.set(cacheKey, result)
175
+ return result
125
176
  }
126
177
 
127
178
  needsApproval(tool: ToolDefinition, input: Record<string, unknown>): boolean {
@@ -193,6 +244,7 @@ export class PermissionSystem {
193
244
  if (level === 'auto') this.mode = 'auto'
194
245
  else if (level === 'bypass') this.mode = 'bypassPermissions'
195
246
  else this.mode = 'default'
247
+ this.invalidateCache()
196
248
  }
197
249
 
198
250
  getDefaultLevel(): PermissionLevel {
@@ -229,11 +281,13 @@ export class PermissionSystem {
229
281
  else if (rule.level === 'ask') this.ask(rule.toolName)
230
282
  }
231
283
  }
284
+ this.invalidateCache()
232
285
  }
233
286
 
234
287
  removeRule(toolName: string): void {
235
288
  this.legacyRules.delete(toolName)
236
289
  this.removeRuleFromArrays(toolName)
290
+ this.invalidateCache()
237
291
  }
238
292
 
239
293
  private removeRuleFromArrays(toolName: string): void {
package/src/index.tsx CHANGED
@@ -1,7 +1,7 @@
1
1
  import { join } from 'node:path'
2
2
  import { render } from 'ink'
3
3
  import { App } from './ui/app'
4
- import { loadConfig } from './config/loader'
4
+ import { loadConfig, loadInferenceHookConfig, loadCredentialMaskingConfig } from './config/loader'
5
5
  import { bootstrapProviders } from './providers/bootstrap'
6
6
  import { InstructionsLoader } from './core/instructions'
7
7
  import { loadSessionMemories } from './core/memory/memory-loader'
@@ -18,6 +18,7 @@ import { registerMcpServerTools } from './mcp/registry'
18
18
  import { AgentRegistry } from './agent/agent-registry'
19
19
  import { HookEngine } from './core/hooks'
20
20
  import { ArtifactServer } from './artifacts/server'
21
+ import { getMetrics } from './core/metrics'
21
22
  import { ARTIFACTS_DIR, ARTIFACT_PORT, MIPHAM_DIR } from './shared/constants'
22
23
  import { AgentViewManager } from './agent-view/agent-view-manager'
23
24
  import { AgentViewDashboard } from './agent-view/dashboard'
@@ -32,6 +33,9 @@ interface RunOptions {
32
33
  }
33
34
 
34
35
  export async function runApp(options: RunOptions): Promise<void> {
36
+ // Metrics: count CLI invocation
37
+ getMetrics().cliInvocations.inc()
38
+
35
39
  // Handle `mipham agents` subcommand — launch standalone dashboard
36
40
  const args = process.argv.slice(2)
37
41
  if (args[0] === 'agents') {
@@ -149,6 +153,17 @@ export async function runApp(options: RunOptions): Promise<void> {
149
153
  engine.setAgentViewManager(agentViewManager)
150
154
  engine.setSkillsLoader(skillsLoader)
151
155
 
156
+ // Wire inference hooks (DLP) configuration
157
+ const inferenceHookConfig = loadInferenceHookConfig()
158
+ engine.setInferenceHookConfig(inferenceHookConfig)
159
+
160
+ // Wire credential masking configuration into tools
161
+ const credentialMaskingConfig = loadCredentialMaskingConfig()
162
+ const { setCredentialMaskingConfigForRead } = await import('./tools/file/read')
163
+ const { setCredentialMaskingConfigForBash } = await import('./tools/exec/bash')
164
+ setCredentialMaskingConfigForRead(credentialMaskingConfig)
165
+ setCredentialMaskingConfigForBash(credentialMaskingConfig)
166
+
152
167
  // Sync engine permission with config (fix: UI shows "auto" but engine defaulted to bypass-legacy)
153
168
  if (config.permission) {
154
169
  engine.getPermission().setDefaultLevel(config.permission as PermissionLevel)
@@ -45,6 +45,7 @@ export class PluginManager {
45
45
  mkdirSync(destDir, { recursive: true })
46
46
  this.copyDir(sourcePath, destDir)
47
47
 
48
+ const similarWarning = this.findSimilarWarning(validation.manifest.name)
48
49
  this.plugins.push({
49
50
  name: validation.manifest.name,
50
51
  version: validation.manifest.version,
@@ -56,7 +57,9 @@ export class PluginManager {
56
57
  this.saveState()
57
58
  return {
58
59
  success: true,
59
- message: `Plugin "${validation.manifest.name}" v${validation.manifest.version} installed`,
60
+ message:
61
+ `Plugin "${validation.manifest.name}" v${validation.manifest.version} installed` +
62
+ (similarWarning ? `\n⚠ ${similarWarning}` : ''),
60
63
  }
61
64
  }
62
65
 
@@ -120,9 +123,12 @@ export class PluginManager {
120
123
  })
121
124
 
122
125
  this.saveState()
126
+ const similarWarning = this.findSimilarWarning(pluginName)
123
127
  return {
124
128
  success: true,
125
- message: `Plugin "${pluginName}" installed from npm`,
129
+ message:
130
+ `Plugin "${pluginName}" installed from npm` +
131
+ (similarWarning ? `\n⚠ ${similarWarning}` : ''),
126
132
  }
127
133
  } catch (err: unknown) {
128
134
  const msg = err instanceof Error ? err.message : String(err)
@@ -140,6 +146,37 @@ export class PluginManager {
140
146
  return [...this.plugins]
141
147
  }
142
148
 
149
+ /**
150
+ * Check if the new plugin name is similar to any existing installed plugin.
151
+ * Warns about potential name squatting / confusion.
152
+ * Returns a warning string or empty string.
153
+ */
154
+ private findSimilarWarning(newName: string): string {
155
+ const existing = this.plugins.map((p) => p.name)
156
+ const similar = existing.filter((name) => this.areNamesSimilar(newName, name))
157
+ if (similar.length === 0) return ''
158
+ return `Similar plugin${similar.length > 1 ? 's' : ''} already installed: ${similar.join(', ')}. Verify you trust this source.`
159
+ }
160
+
161
+ /**
162
+ * Two names are "similar" if:
163
+ * - One is a prefix of the other (e.g. "auth" vs "auth-pro")
164
+ * - They differ by ≤ 2 characters (Levenshtein distance)
165
+ */
166
+ private areNamesSimilar(a: string, b: string): boolean {
167
+ if (a === b) return false // exact match is handled by "already installed" check
168
+ if (a.startsWith(b) || b.startsWith(a)) return true
169
+
170
+ // Simple edit-distance approximation: count differing chars
171
+ const maxLen = Math.max(a.length, b.length)
172
+ let diffs = 0
173
+ for (let i = 0; i < maxLen; i++) {
174
+ if (a[i] !== b[i]) diffs++
175
+ if (diffs > 2) return false
176
+ }
177
+ return diffs <= 2
178
+ }
179
+
143
180
  /**
144
181
  * Register a cleanup callback that will be invoked when the named plugin is removed.
145
182
  * This allows the plugin loader to tear down hooks, agents, MCP connections, and tools.
@@ -196,6 +196,7 @@ export type HookEvent =
196
196
  | 'ConfigChange'
197
197
  | 'SubagentStart'
198
198
  | 'SubagentStop'
199
+ | 'PreInference'
199
200
 
200
201
  export type HookType = 'command' | 'http' | 'code' | 'mcp_tool'
201
202
 
@@ -226,6 +227,18 @@ export interface HookContext {
226
227
  userPrompt?: string
227
228
  configKey?: string
228
229
  configValue?: unknown
230
+ /** PreInference: full conversation messages for DLP inspection. */
231
+ messages?: Array<{ role: string; content: string }>
232
+ /** PreInference: recent tool calls and their results. */
233
+ toolCalls?: Array<{
234
+ name: string
235
+ input: Record<string, unknown>
236
+ resultPreview: string
237
+ }>
238
+ /** PreInference: current provider ID. */
239
+ provider?: string
240
+ /** PreInference: current model ID. */
241
+ model?: string
229
242
  }
230
243
 
231
244
  export interface HookResult {
@@ -278,3 +291,90 @@ export interface PermissionRule {
278
291
  level: PermissionLevel
279
292
  pattern?: string
280
293
  }
294
+
295
+ // ── Inference Hook (DLP) Types ──
296
+
297
+ /** Configuration for the PreInference DLP hook, loaded from config.yml. */
298
+ export interface InferenceHookConfig {
299
+ /** DLP server endpoint (HTTPS). Empty = feature disabled. */
300
+ endpoint: string
301
+ /** HMAC signing secret (format: mis_<random>). */
302
+ signing_secret: string
303
+ /** Request timeout in milliseconds. Default 5000. */
304
+ timeout: number
305
+ /** Failure posture: 'fail-closed' blocks on error, 'fail-open' allows. */
306
+ on_failure: 'fail-closed' | 'fail-open'
307
+ /** Organization identifier (optional, sent in payload). */
308
+ organization_id: string
309
+ /** Additional custom headers to send with each request. */
310
+ headers: Record<string, string>
311
+ }
312
+
313
+ /** Outgoing request to the DLP server. */
314
+ export interface InferenceCheckRequest {
315
+ type: 'inference_check'
316
+ id: string
317
+ created_at: string
318
+ data: {
319
+ type: 'pre_inference'
320
+ session_id: string
321
+ organization_id?: string
322
+ provider: string
323
+ model: string
324
+ messages: Array<{ role: string; content: string }>
325
+ tool_calls: Array<{
326
+ name: string
327
+ input: Record<string, unknown>
328
+ result_preview: string
329
+ }>
330
+ }
331
+ }
332
+
333
+ /** Response from the DLP server. */
334
+ export interface InferenceCheckResponse {
335
+ verdict: 'allow' | 'deny'
336
+ reason?: string
337
+ }
338
+
339
+ // ── Credential Masking Types ──
340
+
341
+ /** Full-file masking rule: entire file content replaced with sentinel. */
342
+ export interface CredentialFullMaskRule {
343
+ path: string
344
+ mode: 'full'
345
+ }
346
+
347
+ /** Extract-based masking rule: only regex-matched tokens are replaced. */
348
+ export interface CredentialExtractRule {
349
+ path: string
350
+ mode: 'extract'
351
+ extract: Array<{
352
+ pattern: string
353
+ replacement?: string
354
+ }>
355
+ }
356
+
357
+ export type CredentialFileRule = CredentialFullMaskRule | CredentialExtractRule
358
+
359
+ /** Configuration for credential masking, loaded from config.yml. */
360
+ export interface CredentialMaskingConfig {
361
+ enabled: boolean
362
+ files: CredentialFileRule[]
363
+ output_scrubbing: {
364
+ enabled: boolean
365
+ patterns: string[]
366
+ }
367
+ env_filter: {
368
+ enabled: boolean
369
+ patterns: string[]
370
+ }
371
+ }
372
+
373
+ // ── Background Agent Types ──
374
+
375
+ export interface BackgroundAgentConfig {
376
+ auto_commit: boolean
377
+ auto_push: boolean
378
+ auto_worktree: boolean
379
+ commit_coauthors: boolean
380
+ }
@@ -1,4 +1,11 @@
1
- import type { ToolDefinition } from '../../shared/index.ts'
1
+ import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
2
+
3
+ // Injected at startup — set via index.tsx
4
+ let credentialConfig: CredentialMaskingConfig | undefined
5
+
6
+ export function setCredentialMaskingConfigForBash(config: CredentialMaskingConfig): void {
7
+ credentialConfig = config
8
+ }
2
9
 
3
10
  // ── Dangerous command patterns ──
4
11
  const BLOCKED_PATTERNS = [
@@ -115,19 +122,38 @@ export const bashTool: ToolDefinition = {
115
122
  }
116
123
 
117
124
  try {
125
+ // ── Credential masking: filter sensitive env vars ──
126
+ let spawnEnv: Record<string, string | undefined> | undefined
127
+ if (credentialConfig?.enabled && credentialConfig.env_filter.enabled) {
128
+ const { filterEnv } = await import('../../core/credential-masker')
129
+ spawnEnv = filterEnv(process.env as Record<string, string | undefined>, credentialConfig)
130
+ }
131
+
118
132
  const proc = Bun.spawn(['bash', '-c', command], {
119
133
  cwd: ctx.cwd,
120
134
  stdout: 'pipe',
121
135
  stderr: 'pipe',
136
+ env: spawnEnv,
122
137
  })
123
138
 
124
139
  const timer = setTimeout(() => proc.kill(), timeout)
125
- const output = await new Response(proc.stdout).text()
140
+ const rawOutput = await new Response(proc.stdout).text()
126
141
  const exitCode = await proc.exited
127
142
  clearTimeout(timer)
128
143
 
144
+ // ── Credential masking: scrub output ──
145
+ let output = rawOutput
146
+ if (credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled) {
147
+ const { maskOutput } = await import('../../core/credential-masker')
148
+ output = maskOutput(rawOutput, credentialConfig)
149
+ }
150
+
129
151
  if (exitCode !== 0) {
130
- const stderr = await new Response(proc.stderr).text()
152
+ const rawStderr = await new Response(proc.stderr).text()
153
+ const stderr =
154
+ credentialConfig?.enabled && credentialConfig.output_scrubbing.enabled
155
+ ? (await import('../../core/credential-masker')).maskOutput(rawStderr, credentialConfig)
156
+ : rawStderr
131
157
  return {
132
158
  success: false,
133
159
  content: output.slice(0, 5_000),
@@ -1,7 +1,14 @@
1
1
  import { readFileSync, existsSync, statSync } from 'node:fs'
2
- import type { ToolDefinition } from '../../shared/index.ts'
2
+ import type { ToolDefinition, CredentialMaskingConfig } from '../../shared/index.ts'
3
3
  import { resolveSafe } from '../../security/path'
4
4
 
5
+ // Injected at startup — set via index.tsx
6
+ let credentialConfig: CredentialMaskingConfig | undefined
7
+
8
+ export function setCredentialMaskingConfigForRead(config: CredentialMaskingConfig): void {
9
+ credentialConfig = config
10
+ }
11
+
5
12
  export const readTool: ToolDefinition = {
6
13
  name: 'Read',
7
14
  description: 'Read a file from the local filesystem. Supports offset and limit for large files.',
@@ -37,6 +44,28 @@ export const readTool: ToolDefinition = {
37
44
  const offset = (params.offset as number) || 0
38
45
  const limit = (params.limit as number) || 2000
39
46
  const content = readFileSync(filePath, 'utf-8')
47
+
48
+ // ── Credential masking ──
49
+ if (credentialConfig) {
50
+ const { matchCredentialFile, maskContent, CREDENTIAL_SENTINEL } =
51
+ await import('../../core/credential-masker')
52
+ const rule = matchCredentialFile(filePath, credentialConfig)
53
+ if (rule) {
54
+ const masked = maskContent(content, rule)
55
+ // Full-file mask: return sentinel immediately (offset/limit don't apply)
56
+ if (masked === CREDENTIAL_SENTINEL) {
57
+ return { success: true, content: masked }
58
+ }
59
+ // Extract mode: apply offset/limit on masked content
60
+ const maskedLines = masked.split('\n')
61
+ const maskedSlice = maskedLines.slice(offset, offset + limit)
62
+ const maskedResult = maskedSlice
63
+ .map((l, i) => `${String(offset + i + 1).padStart(6, ' ')}\t${l}`)
64
+ .join('\n')
65
+ return { success: true, content: maskedResult }
66
+ }
67
+ }
68
+
40
69
  const lines = content.split('\n')
41
70
  const slice = lines.slice(offset, offset + limit)
42
71
  const result = slice
package/src/ui/app.tsx CHANGED
@@ -372,6 +372,11 @@ export function App({
372
372
  setPickerOpen((prev) => !prev)
373
373
  return
374
374
  }
375
+ // Ctrl+F → toggle focus mode
376
+ if (_input === '\x06') {
377
+ setFocusMode((prev) => !prev)
378
+ return
379
+ }
375
380
  // Ctrl+O → toggle last tool call expand/collapse
376
381
  if (_input === '\x0f') {
377
382
  setMessages((prev) => {
@@ -480,7 +485,7 @@ export function App({
480
485
  ● {PERMISSION_LABELS[permissionMode]}
481
486
  </Text>
482
487
  <Text dimColor> · Shift+Tab mode</Text>
483
- <Text dimColor> · Ctrl+P model · Ctrl+O expand</Text>
488
+ <Text dimColor> · Ctrl+P model · Ctrl+F focus · Ctrl+O expand</Text>
484
489
  <Text dimColor> · Esc cancel · /help · ← agents</Text>
485
490
  </Box>
486
491
  </Box>
package/src/ui/chat.tsx CHANGED
@@ -18,16 +18,16 @@ function displayCwd(): string {
18
18
  }
19
19
 
20
20
  export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
21
- // In focus mode, show only the last user+assistant exchange
22
- const displayMessages = focusMode ? getLastExchange(messages) : messages
21
+ // In focus mode, compact tool activity into summary lines
22
+ const displayMessages = focusMode ? compactForFocus(messages) : messages
23
23
 
24
24
  return (
25
25
  <Box flexDirection="column" marginY={1} flexGrow={1}>
26
26
  {focusMode && messages.length > 0 && (
27
27
  <Box marginBottom={1}>
28
28
  <Text dimColor>
29
- 🔍 Focus mode — showing last exchange only ({displayMessages.length} of{' '}
30
- {messages.length} messages hidden)
29
+ 🔍 Focus — {countHidden(messages, displayMessages)} hidden · /focus to toggle · Ctrl+O
30
+ to expand
31
31
  </Text>
32
32
  </Box>
33
33
  )}
@@ -92,22 +92,55 @@ export function ChatPanel({ messages, focusMode }: ChatPanelProps) {
92
92
  }
93
93
 
94
94
  /**
95
- * In focus mode, show only the last user→assistant exchange.
96
- * Walk backwards from the end to find the last user message, then include
97
- * everything from that point onward.
95
+ * Compact messages for focus view:
96
+ * - Groups consecutive tool calls into [▶ N tools] ↳ name1 ↳ name2 summary lines
97
+ * - Keeps all user, assistant, and system messages intact
98
98
  */
99
- function getLastExchange(messages: ChatMessage[]): ChatMessage[] {
100
- if (messages.length <= 2) return messages
99
+ function compactForFocus(messages: ChatMessage[]): ChatMessage[] {
100
+ const result: ChatMessage[] = []
101
+ let toolGroup: ChatMessage[] = []
101
102
 
102
- // Find the last user message index
103
- let lastUserIdx = -1
104
- for (let i = messages.length - 1; i >= 0; i--) {
105
- if (messages[i]!.role === 'user') {
106
- lastUserIdx = i
107
- break
103
+ for (const msg of messages) {
104
+ if (msg.toolMeta) {
105
+ toolGroup.push(msg)
106
+ } else {
107
+ // Flush pending tool group
108
+ if (toolGroup.length > 0) {
109
+ result.push(summarizeToolGroup(toolGroup))
110
+ toolGroup = []
111
+ }
112
+ result.push(msg)
108
113
  }
109
114
  }
115
+ // Flush remaining
116
+ if (toolGroup.length > 0) {
117
+ result.push(summarizeToolGroup(toolGroup))
118
+ }
119
+
120
+ return result
121
+ }
122
+
123
+ /** Summarize a group of tool calls into a single compact message. */
124
+ function summarizeToolGroup(tools: ChatMessage[]): ChatMessage {
125
+ const names = tools.map((t) => t.toolMeta!.name)
126
+ const uniqueNames = [...new Set(names)]
127
+ const toolList = uniqueNames.map((n) => `↳ ${n}`).join(' ')
128
+ return {
129
+ role: 'system',
130
+ content: `[▶ ${tools.length} tool${tools.length > 1 ? 's' : ''}] ${toolList}`,
131
+ toolMeta: {
132
+ name: `tools (${tools.length})`,
133
+ input: '',
134
+ output: tools.map((t) => t.content).join('\n'),
135
+ collapsed: true,
136
+ },
137
+ }
138
+ }
110
139
 
111
- if (lastUserIdx === -1) return messages
112
- return messages.slice(lastUserIdx)
140
+ /** Count how many messages are hidden in focus mode. */
141
+ function countHidden(all: ChatMessage[], compacted: ChatMessage[]): string {
142
+ const toolCount = all.filter((m) => m.toolMeta).length
143
+ const hidden = all.length - compacted.length
144
+ if (hidden <= 0) return `showing all ${all.length} messages`
145
+ return `${hidden} tool outputs folded (${toolCount} total tool calls)`
113
146
  }