@miphamai/cli 0.59.0 → 0.60.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@miphamai/cli",
3
- "version": "0.59.0",
3
+ "version": "0.60.0",
4
4
  "description": "Mipham Code — Multi-model open-core intelligent coding terminal by MiphamAI",
5
5
  "keywords": [
6
6
  "ai",
@@ -8,7 +8,7 @@ function ensureDir(): void {
8
8
  if (!existsSync(AUTOLOOP_DIR)) mkdirSync(AUTOLOOP_DIR, { recursive: true })
9
9
  }
10
10
 
11
- interface AutoloopJournal {
11
+ export interface AutoloopJournal {
12
12
  sessionId: string
13
13
  prompt: string
14
14
  status: 'active' | 'completed' | 'stopped'
@@ -16,6 +16,9 @@ interface AutoloopJournal {
16
16
  startedAt: string
17
17
  lastIteration?: string
18
18
  logs: string[]
19
+ startTokens?: number
20
+ totalTokens: number
21
+ maxIterations: number
19
22
  }
20
23
 
21
24
  function journalPath(sessionId: string): string {
@@ -23,7 +26,11 @@ function journalPath(sessionId: string): string {
23
26
  }
24
27
 
25
28
  /** Create or reset an autonomous loop journal. */
26
- export function createAutoloopJournal(sessionId: string, prompt: string): AutoloopJournal {
29
+ export function createAutoloopJournal(
30
+ sessionId: string,
31
+ prompt: string,
32
+ startTokens = 0,
33
+ ): AutoloopJournal {
27
34
  ensureDir()
28
35
  const journal: AutoloopJournal = {
29
36
  sessionId,
@@ -32,11 +39,22 @@ export function createAutoloopJournal(sessionId: string, prompt: string): Autolo
32
39
  iterations: 0,
33
40
  startedAt: new Date().toISOString(),
34
41
  logs: [],
42
+ startTokens,
43
+ totalTokens: 0,
44
+ maxIterations: 100,
35
45
  }
36
46
  writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
37
47
  return journal
38
48
  }
39
49
 
50
+ /** Accumulate token usage into the loop journal. */
51
+ export function recordLoopTokens(sessionId: string, delta: number): void {
52
+ const journal = readAutoloopJournal(sessionId)
53
+ if (!journal) return
54
+ journal.totalTokens += delta
55
+ writeFileSync(journalPath(sessionId), JSON.stringify(journal, null, 2), 'utf-8')
56
+ }
57
+
40
58
  /** Read the journal for an autonomous loop. */
41
59
  export function readAutoloopJournal(sessionId: string): AutoloopJournal | null {
42
60
  const path = journalPath(sessionId)
@@ -48,6 +66,19 @@ export function readAutoloopJournal(sessionId: string): AutoloopJournal | null {
48
66
  }
49
67
  }
50
68
 
69
+ /** End-of-loop-turn accounting: log the iteration, record the token delta, and
70
+ * stop the loop if it has reached its max-iteration guard (spec §七). */
71
+ export function recordLoopTurn(sessionId: string, summary: string, delta: number): void {
72
+ const journal = readAutoloopJournal(sessionId)
73
+ if (!journal || journal.status !== 'active') return
74
+ logAutoloopIteration(sessionId, summary)
75
+ recordLoopTokens(sessionId, delta)
76
+ const updated = readAutoloopJournal(sessionId)
77
+ if (updated && updated.iterations >= updated.maxIterations) {
78
+ completeAutoloopJournal(sessionId, 'stopped')
79
+ }
80
+ }
81
+
51
82
  /** Log an iteration in the autonomous loop journal. */
52
83
  export function logAutoloopIteration(sessionId: string, summary: string): void {
53
84
  const journal = readAutoloopJournal(sessionId)
@@ -85,6 +116,15 @@ export function listActiveAutoloops(): AutoloopJournal[] {
85
116
  return journals
86
117
  }
87
118
 
119
+ /** Format active loop journals as one-line rows for the /usage panel. */
120
+ export function formatLoopRows(journals: AutoloopJournal[]): string[] {
121
+ return journals.map((j) => {
122
+ const per = j.iterations > 0 ? Math.round(j.totalTokens / j.iterations) : 0
123
+ const last = j.lastIteration ? new Date(j.lastIteration).toLocaleString() : 'N/A'
124
+ return `${j.sessionId.slice(-8)} 🔄 ${j.iterations} iterations · ${j.totalTokens} tokens · ${per} /run · last ${last}`
125
+ })
126
+ }
127
+
88
128
  /** Get the autonomous loop status message. */
89
129
  export function getAutoloopStatus(sessionId: string): string {
90
130
  const journal = readAutoloopJournal(sessionId)
@@ -13,6 +13,7 @@ export const DEFAULT_CONFIG: MiphamConfig = {
13
13
  defaultProvider: 'deepseek',
14
14
  defaultModel: 'deepseek-v4-pro',
15
15
  permission: 'default',
16
+ showThinking: 'minimal',
16
17
  // Org 级权限限制(可选):forbiddenModes 禁指定模式 / maxAllowedMode 封顶层级;
17
18
  // 请求被禁模式时 fail-closed 降级(如 forbiddenModes:['bypassPermissions'])。
18
19
  // permissionRestrictions: { forbiddenModes: ['bypassPermissions'] },
@@ -38,6 +38,7 @@ import { UsageTracker } from './usage-tracker'
38
38
  import { getMetrics } from './metrics'
39
39
  import { buildRequest, sendInferenceCheck, isInferenceHookEnabled } from './inference-hook'
40
40
  import { getFileInboxTransport } from '../agent/cross-session/file-inbox'
41
+ import { registerWakeupHandler } from '../tools/scheduling/schedule-wakeup'
41
42
  import { accumulateGraftSavings } from '../shared/graft-savings'
42
43
  import { getMessageBus, type AgentMessage } from '../agent/message-bus'
43
44
  import { createT } from '../i18n-core/t'
@@ -101,6 +102,12 @@ export class QueryEngine {
101
102
  /** 注入的 LLM chat 缝。未设置时回退 this.registry(strangler-fig)。 */
102
103
  private llm?: Llm
103
104
 
105
+ /** Pending /loop re-invocation prompts, enqueued by the ScheduleWakeup timer. */
106
+ private wakeupQueue: Array<{ prompt: string; noop: boolean }> = []
107
+
108
+ /** Callback fired whenever a wakeup is enqueued (the ScheduleWakeup timer fired). */
109
+ private onWakeupEnqueued: (() => void) | null = null
110
+
104
111
  constructor(
105
112
  private registry: ProviderRegistry,
106
113
  private context: ContextManager,
@@ -109,6 +116,36 @@ export class QueryEngine {
109
116
  ruleEngine?: ExperienceRuleEngine,
110
117
  ) {
111
118
  this.ruleEngine = ruleEngine
119
+ // ScheduleWakeup 的 timer 到期后,把 loop prompt 交回本引擎 re-invoke。
120
+ registerWakeupHandler((_sessionId, prompt, noop) => this.enqueueWakeup(prompt, noop))
121
+ }
122
+
123
+ /** Enqueue a loop prompt for later re-invocation (keep-latest — drops older wakeups). */
124
+ enqueueWakeup(prompt: string, noop = false): void {
125
+ // 队列只保留最新(丢弃旧唤醒,防堆积)
126
+ this.wakeupQueue = [{ prompt, noop }]
127
+ this.onWakeupEnqueued?.()
128
+ }
129
+
130
+ /** Register a callback fired whenever a wakeup is enqueued (the ScheduleWakeup
131
+ * timer fired). The app subscribes to drain the queue while idle. */
132
+ setOnWakeupEnqueued(cb: (() => void) | null): void {
133
+ this.onWakeupEnqueued = cb
134
+ }
135
+
136
+ /** Empty the wakeup queue (stops re-invocation at the max-iteration guard). */
137
+ clearWakeupQueue(): void {
138
+ this.wakeupQueue = []
139
+ }
140
+
141
+ /** Pop the next pending wakeup prompt, or null when the queue is empty. */
142
+ dequeueWakeup(): { prompt: string; noop: boolean } | null {
143
+ return this.wakeupQueue.shift() ?? null
144
+ }
145
+
146
+ /** Whether a wakeup prompt is pending re-invocation. */
147
+ hasPendingWakeup(): boolean {
148
+ return this.wakeupQueue.length > 0
112
149
  }
113
150
 
114
151
  /** Session identifier for cross-session messaging. Set by the app startup via setSessionId(). */
@@ -486,8 +523,11 @@ export class QueryEngine {
486
523
  yield chunk
487
524
 
488
525
  if (chunk.type === 'error') {
526
+ // #23: client-generated errors are system lines, not model output —
527
+ // store as `system` so resume renders an error line (⚠) rather than
528
+ // attributing the failure to the assistant.
489
529
  this.context.addMessage({
490
- role: 'assistant',
530
+ role: 'system',
491
531
  content: t('errors.model_error', { error: chunk.error ?? 'Unknown error' }),
492
532
  })
493
533
  return
@@ -964,7 +1004,7 @@ export class QueryEngine {
964
1004
  // P1-4: Increment consecutive block counter; if limit exceeded,
965
1005
  // tell the model to move on instead of retrying.
966
1006
  const limitExceeded = this.permission.incrementBlockCounter()
967
- const baseError = t('errors.tool_not_allowed', { name })
1007
+ const baseError = this.buildDenialError(name, tool, params)
968
1008
  const moveOnHint = limitExceeded
969
1009
  ? '\n(Consecutive block limit reached. Please try a different approach or ask the user for guidance.)'
970
1010
  : ''
@@ -1297,6 +1337,29 @@ export class QueryEngine {
1297
1337
  return this.tools
1298
1338
  }
1299
1339
 
1340
+ /**
1341
+ * Build a rich permission-denial error naming the mode (level), the setting
1342
+ * (rule/level) that caused the denial, and the correct fix (#52).
1343
+ */
1344
+ private buildDenialError(
1345
+ name: string,
1346
+ tool: ToolDefinition,
1347
+ params: Record<string, unknown>,
1348
+ ): string {
1349
+ const { reason, rulePattern } = this.permission.explainDenial(tool, params)
1350
+ const mode = this.permission.getMode()
1351
+ switch (reason) {
1352
+ case 'deny-rule':
1353
+ return t('errors.tool_denied_deny_rule', { name, pattern: rulePattern ?? '?' })
1354
+ case 'ask-rule':
1355
+ return t('errors.tool_denied_ask_rule', { name, pattern: rulePattern ?? '?' })
1356
+ default:
1357
+ // mode-baseline / tool-default / legacy-rule / system-default — a mode
1358
+ // switch (or /permissions) resolves it.
1359
+ return t('errors.tool_denied_mode', { name, mode })
1360
+ }
1361
+ }
1362
+
1300
1363
  getRuleEngine(): ExperienceRuleEngine | undefined {
1301
1364
  return this.ruleEngine
1302
1365
  }
@@ -62,6 +62,18 @@ function isVerificationCommand(input: Record<string, unknown>): boolean {
62
62
 
63
63
  const VALID_MODES: Set<string> = new Set<string>(MODE_CYCLE)
64
64
 
65
+ /**
66
+ * Why a tool resolved to 'ask' — for rich denial errors (#52).
67
+ * Mirrors the resolution chain order in `check()` (first match wins).
68
+ */
69
+ export type PermissionDenialReason =
70
+ | 'deny-rule' // org deny rule — overrides mode, cannot be switched away
71
+ | 'ask-rule' // explicit ask rule — still requires approval under any mode
72
+ | 'legacy-rule' // legacy exact-name rule (setRule)
73
+ | 'mode-baseline' // mode-specific default (acceptEdits/plan) → ask
74
+ | 'tool-default' // tool.permission === 'ask'
75
+ | 'system-default' // no rule, no tool permission → fallback ask
76
+
65
77
  export class PermissionSystem {
66
78
  private allowRules: PermissionRuleEntry[] = []
67
79
  private denyRules: PermissionRuleEntry[] = []
@@ -344,6 +356,39 @@ export class PermissionSystem {
344
356
  return this.check(tool, input) === 'bypass'
345
357
  }
346
358
 
359
+ /**
360
+ * Explain WHY a tool resolves to 'ask' (rich denial errors — #52).
361
+ *
362
+ * Only call after `check()` returns 'ask'; mirrors its resolution order
363
+ * (deny → ask → legacy → mode → tool → system). Returns the matched
364
+ * rule pattern when the denial came from a deny/ask rule.
365
+ */
366
+ explainDenial(
367
+ tool: ToolDefinition,
368
+ input: Record<string, unknown>,
369
+ ): { reason: PermissionDenialReason; rulePattern?: string } {
370
+ for (const rule of this.denyRules) {
371
+ if (this.ruleMatches(rule, tool, input)) {
372
+ return { reason: 'deny-rule', rulePattern: rule.pattern }
373
+ }
374
+ }
375
+ for (const rule of this.askRules) {
376
+ if (this.ruleMatches(rule, tool, input)) {
377
+ return { reason: 'ask-rule', rulePattern: rule.pattern }
378
+ }
379
+ }
380
+ if (this.legacyRules.has(tool.name)) {
381
+ return { reason: 'legacy-rule' }
382
+ }
383
+ if (this.modeBaseline(tool, input) !== 'mode-baseline') {
384
+ return { reason: 'mode-baseline' }
385
+ }
386
+ if (tool.permission) {
387
+ return { reason: 'tool-default' }
388
+ }
389
+ return { reason: 'system-default' }
390
+ }
391
+
347
392
  // ── Helpers ──
348
393
 
349
394
  private ruleMatches(
@@ -364,6 +364,7 @@
364
364
  "provider_line": "Provider: {provider}",
365
365
  "model_line": "Model: {model}",
366
366
  "tool_section": "── Per-Tool ──",
367
+ "loops_header": "── Autonomous Loops ──",
367
368
  "hint": "Use /context for detailed stats, /compact to free space."
368
369
  },
369
370
  "reload_skills": {
@@ -940,6 +941,9 @@
940
941
  },
941
942
  "errors": {
942
943
  "tool_not_allowed": "Tool \"{name}\" requires user approval (permission: ask). The tool was not executed. Press Shift+Tab to switch permission mode, or run /permissions.",
944
+ "tool_denied_deny_rule": "Tool \"{name}\" blocked by a deny rule (\"{pattern}\"). Deny rules override permission mode — try a different approach.",
945
+ "tool_denied_ask_rule": "Tool \"{name}\" requires approval (ask rule: \"{pattern}\"). Approve when prompted, or adjust the rule via /permissions.",
946
+ "tool_denied_mode": "Tool \"{name}\" requires approval under \"{mode}\" mode. Press Shift+Tab to switch permission mode, or run /permissions.",
943
947
  "tool_blocked": "Tool \"{name}\" blocked by hook",
944
948
  "user_input_blocked": "User input blocked by hook.",
945
949
  "dlp_blocked": "Request blocked by DLP policy.",
@@ -364,6 +364,7 @@
364
364
  "provider_line": "提供商: {provider}",
365
365
  "model_line": "模型: {model}",
366
366
  "tool_section": "── 按工具 ──",
367
+ "loops_header": "── 自动循环 ──",
367
368
  "hint": "使用 /context 查看详细统计,/compact 释放空间。"
368
369
  },
369
370
  "reload_skills": {
@@ -940,6 +941,9 @@
940
941
  },
941
942
  "errors": {
942
943
  "tool_not_allowed": "工具 \"{name}\" 需要用户批准(权限:ask),未执行。按 Shift+Tab 切换权限模式,或运行 /permissions。",
944
+ "tool_denied_deny_rule": "工具 \"{name}\" 被拒绝规则(\"{pattern}\")阻止。拒绝规则优先于权限模式 — 请改用其他方式。",
945
+ "tool_denied_ask_rule": "工具 \"{name}\" 需要批准(ask 规则:\"{pattern}\")。请在提示时批准,或通过 /permissions 调整规则。",
946
+ "tool_denied_mode": "工具 \"{name}\" 在 \"{mode}\" 模式下需要批准。按 Shift+Tab 切换权限模式,或运行 /permissions。",
943
947
  "tool_blocked": "工具 \"{name}\" 被钩子拦截",
944
948
  "user_input_blocked": "用户输入被钩子拦截。",
945
949
  "dlp_blocked": "请求被 DLP 策略阻止。",
@@ -21,7 +21,7 @@ export class OpenAICompatProvider implements ProviderInstance {
21
21
  model: req.model,
22
22
  messages: this.convertMessages(req.messages, req.systemPrompt),
23
23
  stream: true,
24
- max_tokens: req.maxTokens,
24
+ max_tokens: req.maxTokens || 8192,
25
25
  temperature: req.temperature,
26
26
  tools: req.tools?.map((t) => ({ type: 'function', function: t })),
27
27
  }
@@ -9,7 +9,7 @@
9
9
  export const PACKAGE_NAME = '@miphamai/cli' as const
10
10
 
11
11
  /** 当前发布版本 */
12
- export const PACKAGE_VERSION = '0.59.0' as const
12
+ export const PACKAGE_VERSION = '0.60.0' as const
13
13
 
14
14
  /** npm install 全局安装命令 */
15
15
  export const NPM_INSTALL_COMMAND = `npm install -g ${PACKAGE_NAME}` as const
@@ -135,6 +135,13 @@ export interface MiphamConfig {
135
135
  permissionRestrictions?: PermissionRestrictions
136
136
  /** User-defined permission rules (allow/deny patterns), wired into the runtime PermissionSystem. */
137
137
  permissionRules?: { allow?: string[]; deny?: string[] }
138
+ /**
139
+ * How to render the model's reasoning/thinking before the answer:
140
+ * - `off` → hide entirely
141
+ * - `minimal` → content-free "thinking…" indicator (default)
142
+ * - `full` → last 200 chars of the actual thinking text
143
+ */
144
+ showThinking?: 'off' | 'minimal' | 'full'
138
145
  providers: ProviderConfig[]
139
146
  skills?: { paths: string[]; mcpServers: McpServerConfig[] }
140
147
  marketplace?: {
@@ -34,6 +34,12 @@ export function launchApp(appName: string): { success: boolean; message: string
34
34
  }
35
35
  return { success: true, message: `Launched: ${appName}` }
36
36
  } catch (err) {
37
- return { success: false, message: `Failed to launch ${appName}: ${String(err)}` }
37
+ // #57: on macOS an `open -a` failure is often missing Automation permission —
38
+ // point the user at the fix instead of a raw error.
39
+ const macHint =
40
+ process.platform === 'darwin'
41
+ ? ' On macOS, grant Automation permission (System Settings → Privacy & Security → Automation) if prompted.'
42
+ : ''
43
+ return { success: false, message: `Failed to launch ${appName}: ${String(err)}${macHint}` }
38
44
  }
39
45
  }
@@ -52,6 +52,12 @@ $bitmap.Dispose()
52
52
  } catch {
53
53
  // ignore cleanup failures
54
54
  }
55
- return { success: false, error: `Screenshot failed: ${String(err)}` }
55
+ // #57: on macOS a screencapture failure is almost always missing Screen
56
+ // Recording permission — point the user at the fix instead of a raw error.
57
+ const macHint =
58
+ process.platform === 'darwin'
59
+ ? '\n(macOS: grant Screen Recording permission in System Settings → Privacy & Security → Screen Recording)'
60
+ : ''
61
+ return { success: false, error: `Screenshot failed: ${String(err)}${macHint}` }
56
62
  }
57
63
  }
@@ -6,6 +6,15 @@ import type { ToolDefinition } from '../../shared/index.ts'
6
6
  */
7
7
  const activeTimers = new Map<string, ReturnType<typeof setTimeout>>()
8
8
 
9
+ let wakeupHandler: ((sessionId: string, prompt: string, noop?: boolean) => void) | null = null
10
+
11
+ /** 引擎在启动时注入——timer 到期后回调,把 loop prompt 交回引擎 re-invoke。 */
12
+ export function registerWakeupHandler(
13
+ fn: (sessionId: string, prompt: string, noop?: boolean) => void,
14
+ ): void {
15
+ wakeupHandler = fn
16
+ }
17
+
9
18
  export const scheduleWakeupTool: ToolDefinition = {
10
19
  name: 'ScheduleWakeup',
11
20
  description:
@@ -31,6 +40,10 @@ export const scheduleWakeupTool: ToolDefinition = {
31
40
  type: 'boolean',
32
41
  description: 'Set to true to end the dynamic loop immediately.',
33
42
  },
43
+ noop: {
44
+ type: 'boolean',
45
+ description: 'true = 本轮无事可报(仅用于 UI 折叠空闲提示,不改变 re-invoke 语义)',
46
+ },
34
47
  },
35
48
  required: [],
36
49
  },
@@ -58,6 +71,7 @@ export const scheduleWakeupTool: ToolDefinition = {
58
71
  const delaySeconds = params.delaySeconds as number
59
72
  const prompt = (params.prompt as string) || ''
60
73
  const reason = (params.reason as string) || 'scheduled wakeup'
74
+ const noop = params.noop === true
61
75
 
62
76
  if (!delaySeconds || typeof delaySeconds !== 'number') {
63
77
  return {
@@ -86,6 +100,7 @@ export const scheduleWakeupTool: ToolDefinition = {
86
100
  const timerKey = `${sessionId}:wakeup`
87
101
  const timeoutId = setTimeout(() => {
88
102
  activeTimers.delete(timerKey)
103
+ wakeupHandler?.(sessionId, prompt, noop)
89
104
  }, delaySeconds * 1000)
90
105
 
91
106
  activeTimers.set(timerKey, timeoutId)
package/src/ui/app.tsx CHANGED
@@ -2,6 +2,7 @@ import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'
2
2
  import { Box, Text, useInput } from 'ink'
3
3
  import TextInput from 'ink-text-input'
4
4
  import { ErrorBoundary } from './error-boundary'
5
+ import { formatThinking } from './thinking'
5
6
  import type { QueryEngine } from '../core/engine'
6
7
  import type { RemoteEngine } from '../daemon/remote-engine'
7
8
  import type { MiphamConfig } from '../shared/index.ts'
@@ -23,6 +24,7 @@ import { InputBar } from './input'
23
24
  import { ModelPicker } from './picker'
24
25
  import { AgentFooter, type AgentEntry } from './agent-footer'
25
26
  import { GraftStatusLine } from './graft-status'
27
+ import { collapseNoopTicks } from './loop-noop'
26
28
 
27
29
  /** Current context-window usage % — undefined when unknown (remote stub). */
28
30
  function contextUsagePct(engine: QueryEngine | RemoteEngine): number | undefined {
@@ -48,6 +50,8 @@ import {
48
50
  import { useI18n } from '../i18n-context'
49
51
  import type { PermissionMode } from '../shared/index.ts'
50
52
  import { sanitizeForDisplay } from '../shared/sanitize.ts'
53
+ import { recordLoopTurn, readAutoloopJournal } from '../commands/autoloop-journal.js'
54
+ import { cancelAllSessionTimers } from '../tools/scheduling/schedule-wakeup.js'
51
55
 
52
56
  interface AppProps {
53
57
  engine: QueryEngine | RemoteEngine
@@ -160,6 +164,10 @@ export function App({
160
164
  )
161
165
  const [messages, setMessages] = useState<ChatMessage[]>([])
162
166
  const [isLoading, setIsLoading] = useState(false)
167
+ /** Idle-drain tick — bumped by the engine's onEnqueue callback whenever the
168
+ * ScheduleWakeup timer fires while the engine is idle. Drives the idle-drain
169
+ * effect so a queued /loop wakeup re-invokes without waiting for user input. */
170
+ const [wakeupTick, setWakeupTick] = useState(0)
163
171
  const [providerId, setProviderId] = useState(initialProvider || config.defaultProvider)
164
172
  const [modelId, setModelId] = useState(initialModel || config.defaultModel)
165
173
  const [pickerOpen, setPickerOpen] = useState(false)
@@ -181,6 +189,8 @@ export function App({
181
189
  // Monotonic turn id — lets a stale turn's finally() skip resetting shared UI
182
190
  // state (isLoading/abortRef/progress) after a newer turn has already started.
183
191
  const turnIdRef = useRef(0)
192
+ // Consecutive /loop noop wakeup count — used to fold repeated idle turns (#53).
193
+ const noopStreakRef = useRef(0)
184
194
  // Stream buffer: accumulate text chunks and throttle state updates to ~16fps.
185
195
  // Without this, every SSE chunk triggers setMessages → copies full array →
186
196
  // re-renders ChatPanel → re-runs compactToolGroups O(n). At 20-50 chunks/sec
@@ -337,220 +347,50 @@ export function App({
337
347
  [apiKeyPrompt, config, engine],
338
348
  )
339
349
 
340
- const handleSubmit = useCallback(
341
- async (input: string) => {
342
- if (!input.trim()) return
350
+ // ── runTurn: renders a single AI turn (user-submitted or /loop re-invoked) ──
351
+ // Extracted from handleSubmit so scheduled /loop wakeups re-enter the same streaming
352
+ // path. `source` marks user vs loop turns (currently identical rendering — loop turns
353
+ // skip handleSubmit's user-message/emotion/session-setup prelude). `controller` is
354
+ // supplied by user turns (already stored in abortRef); loop turns pass none and runTurn
355
+ // creates one so Escape/abort still works mid-loop-turn.
356
+ const runTurn = useCallback(
357
+ async (input: string, source: 'user' | 'loop', controller?: AbortController, noop = false) => {
358
+ void source // reserved for future user-vs-loop divergence; both paths share this body
343
359
  const turnId = ++turnIdRef.current
360
+ // Loop turns set loading state here (user turns already do it in handleSubmit;
361
+ // the duplicate is idempotent). Without this, the idle-drain effect can't tell
362
+ // a loop turn is running and would drain the queue mid-turn.
363
+ setIsLoading(true)
364
+ const loopStartTokens =
365
+ source === 'loop' && 'hasPendingWakeup' in engine
366
+ ? engine.getUsageTracker().totalApiTokens
367
+ : 0
368
+ const ctrl = controller ?? new AbortController()
369
+ if (!controller) abortRef.current = ctrl
344
370
 
345
- // ── @mention: direct cross-session message — only when the name resolves
346
- // to a live session; otherwise fall through to normal AI processing so a
347
- // leading `@word` (e.g. "@bob please review") isn't hijacked as a send. ──
348
- const mention = parseMention(input)
349
- if (mention && resolveRecipientSession(discoverSessions(), mention.name).session) {
350
- if (!mention.message) {
351
- setMessages((prev) => [
352
- ...prev,
353
- { role: 'user', content: input },
354
- { role: 'system', content: 'Usage: @session-name <message>' },
355
- ])
356
- return
357
- }
358
- const mentionSummary =
359
- mention.message.length > 50 ? mention.message.slice(0, 47) + '...' : mention.message
360
- const result = await getMessageRouter().route(
361
- sessionId || 'main',
362
- mention.name,
363
- mentionSummary,
364
- mention.message,
365
- )
366
- setMessages((prev) => [
367
- ...prev,
368
- { role: 'user', content: input },
369
- {
370
- role: 'system',
371
- content: result.success
372
- ? `── Message sent to ${mention.name} ──${result.messageId ? `\nID: ${result.messageId}` : ''}`
373
- : `❌ Failed to send to ${mention.name}: ${result.error}`,
374
- },
375
- ])
376
- return
371
+ // ── /loop idle folding (#53) ──
372
+ // Count consecutive noop wakeups; any non-noop loop turn or user turn resets.
373
+ if (source === 'loop' && noop) {
374
+ noopStreakRef.current++
375
+ } else {
376
+ noopStreakRef.current = 0
377
377
  }
378
-
379
- // ── Slash command dispatch ──
380
- if (looksLikeSlashCommand(input)) {
381
- const { command, args } = parseSlashCommand(input)
382
-
383
- // /switch takes args, handled separately
384
- if (command === '/switch') {
385
- const result = await handleSwitch(mkCtx(), args)
386
- if (result.needsApiKey) {
387
- setApiKeyPrompt(result.needsApiKey)
388
- setApiKeyInput('')
389
- setMessages((prev) => [
390
- ...prev,
391
- { role: 'user', content: input },
392
- { role: 'system', content: result.content },
393
- ])
378
+ // The first noop tick renders normally; the 2nd+ fold into one idle line.
379
+ const foldIdle = source === 'loop' && noop && noopStreakRef.current >= 2
380
+ if (foldIdle) {
381
+ const ticks = Array.from({ length: noopStreakRef.current }, () => ({ noop: true }))
382
+ const folded = collapseNoopTicks(ticks)
383
+ setMessages((prev) => {
384
+ const updated = [...prev]
385
+ const last = updated[updated.length - 1]
386
+ if (last?.role === 'system' && last.content.startsWith('💤 idle ×')) {
387
+ last.content = folded
394
388
  } else {
395
- setMessages((prev) => [
396
- ...prev,
397
- { role: 'user', content: input },
398
- { role: 'system', content: result.content },
399
- ])
400
- if (result.nextProvider) setProviderId(result.nextProvider)
401
- if (result.nextModel) setModelId(result.nextModel)
402
- }
403
- if (result.exit) process.exit(0)
404
- return
405
- }
406
-
407
- // /pick → open interactive model picker
408
- if (command === '/pick' || command === '/model-picker') {
409
- setPickerOpen(true)
410
- return
411
- }
412
-
413
- // /quit and /exit are special
414
- if (command === '/exit' || command === '/quit') {
415
- process.exit(0)
416
- }
417
-
418
- // /focus toggle
419
- if (command === '/focus') {
420
- const nextFocus = !focusMode
421
- setFocusMode(nextFocus)
422
- setMessages((prev) => [
423
- ...prev,
424
- { role: 'user', content: input },
425
- {
426
- role: 'system',
427
- content: nextFocus
428
- ? '✓ Focus mode ON — showing only the most recent exchange. Type /focus again to show all.'
429
- : '✓ Focus mode OFF — showing all messages.',
430
- },
431
- ])
432
- return
433
- }
434
-
435
- let forwardToAI: string | undefined
436
-
437
- const handler = getCommand(command)
438
- if (handler) {
439
- const result = await handler(mkCtx(), args)
440
- forwardToAI = result.forwardToAI
441
- // Handle API key prompt from command result
442
- if (result.needsApiKey) {
443
- setApiKeyPrompt(result.needsApiKey)
444
- setApiKeyInput('')
445
- }
446
- setMessages((prev) => [
447
- ...prev,
448
- { role: 'user', content: input },
449
- { role: 'system', content: result.content },
450
- ])
451
- if (result.needsApiKey) {
452
- // Don't process nextProvider/model when waiting for API key
453
- if (result.exit) process.exit(0)
454
- return
455
- }
456
- if (result.clearMessages) setMessages([])
457
- if (result.nextProvider) setProviderId(result.nextProvider)
458
- if (result.nextModel) setModelId(result.nextModel)
459
- if (result.exit) process.exit(0)
460
- if (result.forwardedMessages && result.forwardedMessages.length > 0) {
461
- const restored: ChatMessage[] = result.forwardedMessages.map((msg) => ({
462
- role: msg.role,
463
- content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
464
- }))
465
- if (result.resumeWarning) {
466
- // Fix 7: Session isolation — add a visual separator between current session
467
- // and restored history to prevent accidental leakage/confusion
468
- const separator: ChatMessage = {
469
- role: 'assistant' as const,
470
- content: '── Restored session history below ──',
471
- }
472
- setMessages((prev) => [...prev, separator, ...restored])
473
- } else {
474
- setMessages((prev) => [...prev, ...restored])
475
- }
389
+ updated.push({ role: 'system', content: folded })
476
390
  }
477
- if (result.copyContent) {
478
- // Copy to clipboard via pbcopy (macOS) or clip (Windows)
479
- try {
480
- const { execSync } = await import('node:child_process')
481
- if (process.platform === 'darwin') {
482
- execSync('pbcopy', { input: result.copyContent })
483
- } else if (process.platform === 'win32') {
484
- execSync('clip', { input: result.copyContent })
485
- }
486
- // Linux: xclip or wl-copy not attempted to avoid dependency issues
487
- } catch {
488
- // Silent fail — content is still displayed
489
- }
490
- }
491
- }
492
-
493
- // Bridge: if command set forwardToAI, route the message to AI processing
494
- if (forwardToAI) {
495
- input = forwardToAI
496
- // fall through to normal AI processing below
497
- } else {
498
- // No handler matched or handler didn't request AI routing — stop here
499
- return
500
- }
501
- }
502
-
503
- // ── Emotion detection: adjust behavior based on user's emotional state ──
504
- // Uses regex heuristics (zero-latency) to detect frustration/impatience/confusion.
505
- // When frustrated, prepends a terseness instruction to the user input so the
506
- // AI model skips explanations and gets straight to the fix.
507
- let emotionPrefix = ''
508
- try {
509
- const { EmotionDetector } = await import('../core/emotion-detector.js')
510
- const detector = new EmotionDetector()
511
- const result = detector.detect(input)
512
- if (result.emotion === 'frustrated' || result.emotion === 'impatient') {
513
- emotionPrefix = `[SYSTEM NOTE: The user is ${result.emotion}. Be extremely concise. Skip all explanations, preambles, and summaries. Output only the fix/result. No "here's what I did" or "let me explain". One sentence maximum before code.]\n\n`
514
- } else if (result.emotion === 'confused') {
515
- emotionPrefix = `[SYSTEM NOTE: The user seems confused. Explain more thoroughly, break down complex steps, and offer clarifying questions rather than assuming understanding.]\n\n`
516
- }
517
- } catch {
518
- // Emotion detection is non-critical — fail silently
519
- }
520
-
521
- // ── Normal message processing (AI chat) ──
522
- // First user message: auto-name the session if it still carries the
523
- // default cwd-basename name (respecting any manual /rename).
524
- if (
525
- engine
526
- .getContext()
527
- .getMessages()
528
- .every((m) => m.role !== 'user')
529
- ) {
530
- const currentName = discoverSessions().find((s) => s.id === sessionId)?.name
531
- if (isDefaultSessionName(currentName, process.cwd())) {
532
- const title = deriveSessionTitle(input)
533
- if (title && sessionId) renameActiveSession(sessionId, title)
534
- }
535
- }
536
-
537
- setMessages((prev) => [...prev, { role: 'user', content: input }])
538
- setIsLoading(true)
539
-
540
- // Start agent progress indicator immediately for ALL processing
541
- const progressStart = Date.now()
542
- const progress: AgentProgress = {
543
- name: '',
544
- description: '',
545
- startTime: progressStart,
546
- tokensUsed: 0,
391
+ return updated
392
+ })
547
393
  }
548
- agentProgressRef.current = progress
549
- setAgentProgress(progress)
550
- setAgentTick((t) => t + 1)
551
-
552
- const controller = new AbortController()
553
- abortRef.current = controller
554
394
 
555
395
  let assistantContent = ''
556
396
  // Track whether we've started a new assistant turn — reset accumulator per turn
@@ -577,13 +417,11 @@ export function App({
577
417
  }
578
418
 
579
419
  try {
580
- for await (const chunk of engine.process(
581
- emotionPrefix ? emotionPrefix + input : input,
582
- controller.signal,
583
- )) {
420
+ for await (const chunk of engine.process(input, ctrl.signal)) {
584
421
  // Reasoning content (DeepSeek V4 thinking mode) — surface as a live
585
422
  // "thinking" indicator so long reasoning passes don't look like a stall.
586
423
  if (chunk.reasoning_content) {
424
+ if (foldIdle) continue // folded idle turns never surface reasoning
587
425
  thinkingRef.current += chunk.reasoning_content
588
426
  if (!thinkingTimerRef.current) {
589
427
  thinkingTimerRef.current = setTimeout(() => {
@@ -595,6 +433,8 @@ export function App({
595
433
  }
596
434
 
597
435
  if (chunk.type === 'text' && chunk.content) {
436
+ assistantContent += chunk.content
437
+ if (foldIdle) continue // suppress per-turn idle output; folded line already shown
598
438
  // New turn: push fresh assistant message, reset stream buffer
599
439
  if (isNewTurn) {
600
440
  // Flush any accumulated reasoning as a collapsed history line
@@ -638,7 +478,6 @@ export function App({
638
478
  }, 60)
639
479
  }
640
480
  }
641
- assistantContent += chunk.content
642
481
  }
643
482
 
644
483
  if (chunk.type === 'tool_use' && chunk.toolUse) {
@@ -806,8 +645,284 @@ export function App({
806
645
  // Final sync of background agents after the turn completes
807
646
  syncBgAgents()
808
647
  }
648
+
649
+ // End-of-loop-turn accounting: log iteration + token delta, and stop at the
650
+ // max-iteration guard. `input` is the loop id for /loop auto (ScheduleWakeup
651
+ // re-invokes runTurn(loopId, 'loop')); fixed-interval loops have no journal and skip.
652
+ if (source === 'loop' && 'hasPendingWakeup' in engine) {
653
+ const journal = readAutoloopJournal(input)
654
+ if (journal && journal.status === 'active') {
655
+ const delta = engine.getUsageTracker().totalApiTokens - loopStartTokens
656
+ recordLoopTurn(input, assistantContent.slice(0, 200), delta)
657
+ // max-iteration guard: recordLoopTurn flips the journal to 'stopped' when
658
+ // iterations hit maxIterations. Stop re-invocation so the loop doesn't wake
659
+ // again after hitting the cap.
660
+ const after = readAutoloopJournal(input)
661
+ if (after && after.status !== 'active') {
662
+ if (sessionId) cancelAllSessionTimers(sessionId)
663
+ engine.clearWakeupQueue()
664
+ }
665
+ }
666
+ }
667
+
668
+ // Turn finished — drain any /loop wakeup queued while we were running.
669
+ drainLoopQueueRef.current?.(turnId)
670
+ },
671
+ [engine, syncBgAgents],
672
+ )
673
+
674
+ // Ref-held drain breaks the runTurn ↔ drain circular useCallback dependency: runTurn
675
+ // calls drainLoopQueueRef.current (a stable ref object), and the drain closure captures
676
+ // the latest runTurn. Reassigned each render so it never holds a stale runTurn.
677
+ const drainLoopQueueRef = useRef<((turnId: number) => Promise<void>) | null>(null)
678
+ drainLoopQueueRef.current = async (turnId: number) => {
679
+ // User turn wins: if a user submitted meanwhile, turnId advanced and this loop wakeup
680
+ // yields (the next timer fire re-enqueues). Spec §六 — do not force it to run anyway.
681
+ if (turnIdRef.current !== turnId) return
682
+ // RemoteEngine (remote attach mode) has no wakeup queue — /loop is CLI-local.
683
+ if (!('hasPendingWakeup' in engine)) return
684
+ const next = engine.hasPendingWakeup() ? engine.dequeueWakeup() : null
685
+ if (next) await runTurn(next.prompt, 'loop', undefined, next.noop)
686
+ }
687
+
688
+ // ── /loop idle-drain trigger ──
689
+ // The ScheduleWakeup timer fires 60–3600s after the turn that scheduled it, while
690
+ // the engine is idle. At that moment enqueueWakeup only mutates the queue (no React
691
+ // state change, no poller), so the queue would sit undrained forever. Subscribe the
692
+ // engine's onEnqueue callback to a state bump; the idle-drain effect below then
693
+ // drains the queue and re-invokes runTurn.
694
+ useEffect(() => {
695
+ if (!('setOnWakeupEnqueued' in engine)) return
696
+ const qe = engine // narrow to QueryEngine for the cleanup closure
697
+ qe.setOnWakeupEnqueued(() => setWakeupTick((t) => t + 1))
698
+ return () => qe.setOnWakeupEnqueued(null)
699
+ }, [engine])
700
+
701
+ // When a wakeup is enqueued and the engine is idle, drain it now (the running turn's
702
+ // end-drain handles the busy case). `wakeupTick === 0` skips the mount-time run.
703
+ useEffect(() => {
704
+ if (wakeupTick === 0) return
705
+ if (isLoading) return
706
+ drainLoopQueueRef.current?.(turnIdRef.current)
707
+ }, [wakeupTick, isLoading])
708
+
709
+ const handleSubmit = useCallback(
710
+ async (input: string) => {
711
+ if (!input.trim()) return
712
+
713
+ // ── @mention: direct cross-session message — only when the name resolves
714
+ // to a live session; otherwise fall through to normal AI processing so a
715
+ // leading `@word` (e.g. "@bob please review") isn't hijacked as a send. ──
716
+ const mention = parseMention(input)
717
+ if (mention && resolveRecipientSession(discoverSessions(), mention.name).session) {
718
+ if (!mention.message) {
719
+ setMessages((prev) => [
720
+ ...prev,
721
+ { role: 'user', content: input },
722
+ { role: 'system', content: 'Usage: @session-name <message>' },
723
+ ])
724
+ return
725
+ }
726
+ const mentionSummary =
727
+ mention.message.length > 50 ? mention.message.slice(0, 47) + '...' : mention.message
728
+ const result = await getMessageRouter().route(
729
+ sessionId || 'main',
730
+ mention.name,
731
+ mentionSummary,
732
+ mention.message,
733
+ )
734
+ setMessages((prev) => [
735
+ ...prev,
736
+ { role: 'user', content: input },
737
+ {
738
+ role: 'system',
739
+ content: result.success
740
+ ? `── Message sent to ${mention.name} ──${result.messageId ? `\nID: ${result.messageId}` : ''}`
741
+ : `❌ Failed to send to ${mention.name}: ${result.error}`,
742
+ },
743
+ ])
744
+ return
745
+ }
746
+
747
+ // ── Slash command dispatch ──
748
+ if (looksLikeSlashCommand(input)) {
749
+ const { command, args } = parseSlashCommand(input)
750
+
751
+ // /switch takes args, handled separately
752
+ if (command === '/switch') {
753
+ const result = await handleSwitch(mkCtx(), args)
754
+ if (result.needsApiKey) {
755
+ setApiKeyPrompt(result.needsApiKey)
756
+ setApiKeyInput('')
757
+ setMessages((prev) => [
758
+ ...prev,
759
+ { role: 'user', content: input },
760
+ { role: 'system', content: result.content },
761
+ ])
762
+ } else {
763
+ setMessages((prev) => [
764
+ ...prev,
765
+ { role: 'user', content: input },
766
+ { role: 'system', content: result.content },
767
+ ])
768
+ if (result.nextProvider) setProviderId(result.nextProvider)
769
+ if (result.nextModel) setModelId(result.nextModel)
770
+ }
771
+ if (result.exit) process.exit(0)
772
+ return
773
+ }
774
+
775
+ // /pick → open interactive model picker
776
+ if (command === '/pick' || command === '/model-picker') {
777
+ setPickerOpen(true)
778
+ return
779
+ }
780
+
781
+ // /quit and /exit are special
782
+ if (command === '/exit' || command === '/quit') {
783
+ process.exit(0)
784
+ }
785
+
786
+ // /focus toggle
787
+ if (command === '/focus') {
788
+ const nextFocus = !focusMode
789
+ setFocusMode(nextFocus)
790
+ setMessages((prev) => [
791
+ ...prev,
792
+ { role: 'user', content: input },
793
+ {
794
+ role: 'system',
795
+ content: nextFocus
796
+ ? '✓ Focus mode ON — showing only the most recent exchange. Type /focus again to show all.'
797
+ : '✓ Focus mode OFF — showing all messages.',
798
+ },
799
+ ])
800
+ return
801
+ }
802
+
803
+ let forwardToAI: string | undefined
804
+
805
+ const handler = getCommand(command)
806
+ if (handler) {
807
+ const result = await handler(mkCtx(), args)
808
+ forwardToAI = result.forwardToAI
809
+ // Handle API key prompt from command result
810
+ if (result.needsApiKey) {
811
+ setApiKeyPrompt(result.needsApiKey)
812
+ setApiKeyInput('')
813
+ }
814
+ setMessages((prev) => [
815
+ ...prev,
816
+ { role: 'user', content: input },
817
+ { role: 'system', content: result.content },
818
+ ])
819
+ if (result.needsApiKey) {
820
+ // Don't process nextProvider/model when waiting for API key
821
+ if (result.exit) process.exit(0)
822
+ return
823
+ }
824
+ if (result.clearMessages) setMessages([])
825
+ if (result.nextProvider) setProviderId(result.nextProvider)
826
+ if (result.nextModel) setModelId(result.nextModel)
827
+ if (result.exit) process.exit(0)
828
+ if (result.forwardedMessages && result.forwardedMessages.length > 0) {
829
+ const restored: ChatMessage[] = result.forwardedMessages.map((msg) => ({
830
+ role: msg.role,
831
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
832
+ }))
833
+ if (result.resumeWarning) {
834
+ // Fix 7: Session isolation — add a visual separator between current session
835
+ // and restored history to prevent accidental leakage/confusion
836
+ const separator: ChatMessage = {
837
+ role: 'assistant' as const,
838
+ content: '── Restored session history below ──',
839
+ }
840
+ setMessages((prev) => [...prev, separator, ...restored])
841
+ } else {
842
+ setMessages((prev) => [...prev, ...restored])
843
+ }
844
+ }
845
+ if (result.copyContent) {
846
+ // Copy to clipboard via pbcopy (macOS) or clip (Windows)
847
+ try {
848
+ const { execSync } = await import('node:child_process')
849
+ if (process.platform === 'darwin') {
850
+ execSync('pbcopy', { input: result.copyContent })
851
+ } else if (process.platform === 'win32') {
852
+ execSync('clip', { input: result.copyContent })
853
+ }
854
+ // Linux: xclip or wl-copy not attempted to avoid dependency issues
855
+ } catch {
856
+ // Silent fail — content is still displayed
857
+ }
858
+ }
859
+ }
860
+
861
+ // Bridge: if command set forwardToAI, route the message to AI processing
862
+ if (forwardToAI) {
863
+ input = forwardToAI
864
+ // fall through to normal AI processing below
865
+ } else {
866
+ // No handler matched or handler didn't request AI routing — stop here
867
+ return
868
+ }
869
+ }
870
+
871
+ // ── Emotion detection: adjust behavior based on user's emotional state ──
872
+ // Uses regex heuristics (zero-latency) to detect frustration/impatience/confusion.
873
+ // When frustrated, prepends a terseness instruction to the user input so the
874
+ // AI model skips explanations and gets straight to the fix.
875
+ let emotionPrefix = ''
876
+ try {
877
+ const { EmotionDetector } = await import('../core/emotion-detector.js')
878
+ const detector = new EmotionDetector()
879
+ const result = detector.detect(input)
880
+ if (result.emotion === 'frustrated' || result.emotion === 'impatient') {
881
+ emotionPrefix = `[SYSTEM NOTE: The user is ${result.emotion}. Be extremely concise. Skip all explanations, preambles, and summaries. Output only the fix/result. No "here's what I did" or "let me explain". One sentence maximum before code.]\n\n`
882
+ } else if (result.emotion === 'confused') {
883
+ emotionPrefix = `[SYSTEM NOTE: The user seems confused. Explain more thoroughly, break down complex steps, and offer clarifying questions rather than assuming understanding.]\n\n`
884
+ }
885
+ } catch {
886
+ // Emotion detection is non-critical — fail silently
887
+ }
888
+
889
+ // ── Normal message processing (AI chat) ──
890
+ // First user message: auto-name the session if it still carries the
891
+ // default cwd-basename name (respecting any manual /rename).
892
+ if (
893
+ engine
894
+ .getContext()
895
+ .getMessages()
896
+ .every((m) => m.role !== 'user')
897
+ ) {
898
+ const currentName = discoverSessions().find((s) => s.id === sessionId)?.name
899
+ if (isDefaultSessionName(currentName, process.cwd())) {
900
+ const title = deriveSessionTitle(input)
901
+ if (title && sessionId) renameActiveSession(sessionId, title)
902
+ }
903
+ }
904
+
905
+ setMessages((prev) => [...prev, { role: 'user', content: input }])
906
+ setIsLoading(true)
907
+
908
+ // Start agent progress indicator immediately for ALL processing
909
+ const progressStart = Date.now()
910
+ const progress: AgentProgress = {
911
+ name: '',
912
+ description: '',
913
+ startTime: progressStart,
914
+ tokensUsed: 0,
915
+ }
916
+ agentProgressRef.current = progress
917
+ setAgentProgress(progress)
918
+ setAgentTick((t) => t + 1)
919
+
920
+ const controller = new AbortController()
921
+ abortRef.current = controller
922
+
923
+ await runTurn(emotionPrefix ? emotionPrefix + input : input, 'user', controller)
809
924
  },
810
- [engine, mkCtx, syncBgAgents],
925
+ [engine, mkCtx, runTurn],
811
926
  )
812
927
 
813
928
  useInput((_input, key) => {
@@ -902,7 +1017,14 @@ export function App({
902
1017
  <>
903
1018
  {/* Chat panel */}
904
1019
  <ChatPanel messages={messages} focusMode={focusMode} />
905
- {thinkingText ? <Text dimColor>💭 {thinkingText.slice(-200)}</Text> : null}
1020
+ {(() => {
1021
+ const indicator = formatThinking(
1022
+ config.showThinking ?? 'minimal',
1023
+ thinkingText,
1024
+ t('ui.loading.thinking'),
1025
+ )
1026
+ return indicator ? <Text dimColor>{indicator}</Text> : null
1027
+ })()}
906
1028
 
907
1029
  {/* Input with separator lines */}
908
1030
  {pickerOpen ? (
@@ -62,6 +62,7 @@ import { commitCmd, pushCmd, prCmd, issueCmd } from '../commands/git.js'
62
62
  import { suggestDirectories } from '../commands/cd-suggest.js'
63
63
  import { keysCmd } from '../commands/keys'
64
64
  import { workflowViewCmd, workflowWatchCmd } from '../commands/workflow-view.js'
65
+ import { listActiveAutoloops, formatLoopRows } from '../commands/autoloop-journal.js'
65
66
  import { execSync } from 'node:child_process'
66
67
  import { OLLAMA_PRESET_MODELS } from '../shared/constants'
67
68
  import { renameActiveSession } from '../agent/cross-session/discovery'
@@ -545,6 +546,7 @@ const configCmd: CommandHandler = (ctx) => {
545
546
  `defaultProvider: ${c.defaultProvider}`,
546
547
  `defaultModel: ${c.defaultModel}`,
547
548
  `permission: ${c.permission}`,
549
+ `showThinking: ${c.showThinking ?? 'minimal'}`,
548
550
  `providers: ${c.providers.length} configured`,
549
551
  '',
550
552
  `crossSessionInbound: ${xs.crossSessionInbound}`,
@@ -2105,6 +2107,12 @@ const usageCmd: CommandHandler = (ctx) => {
2105
2107
  const toolSection =
2106
2108
  toolLines.length > 0 ? `\n${t('commands.usage.tool_section')}\n${toolLines.join('\n')}` : ''
2107
2109
 
2110
+ const loops = listActiveAutoloops()
2111
+ const loopSection =
2112
+ loops.length > 0
2113
+ ? `\n${t('commands.usage.loops_header')}\n${formatLoopRows(loops).join('\n')}`
2114
+ : ''
2115
+
2108
2116
  return {
2109
2117
  content: stripIndent`
2110
2118
  ${t('commands.usage.title')}
@@ -2115,7 +2123,7 @@ const usageCmd: CommandHandler = (ctx) => {
2115
2123
  ${t('commands.usage.model_line', { model: ctx.modelId })}
2116
2124
 
2117
2125
  ${'█'.repeat(Math.ceil(Number(pct) / 5))}${'░'.repeat(20 - Math.ceil(Number(pct) / 5))} ${pct}%
2118
- ${toolSection}
2126
+ ${toolSection}${loopSection}
2119
2127
 
2120
2128
  ${t('commands.usage.hint')}
2121
2129
  `,
@@ -2618,7 +2626,7 @@ function parseInterval(raw: string): number | null {
2618
2626
  return null
2619
2627
  }
2620
2628
 
2621
- const loopCmd: CommandHandler = async (_ctx, args) => {
2629
+ const loopCmd: CommandHandler = async (ctx, args) => {
2622
2630
  const sub = args[0]
2623
2631
 
2624
2632
  // ── /loop init — scaffold project vault ──
@@ -2720,7 +2728,7 @@ const loopCmd: CommandHandler = async (_ctx, args) => {
2720
2728
 
2721
2729
  const sessionId = `autoloop-${Date.now()}`
2722
2730
  const { createAutoloopJournal } = await import('../commands/autoloop-journal.js')
2723
- createAutoloopJournal(sessionId, prompt)
2731
+ createAutoloopJournal(sessionId, prompt, ctx.engine.getUsageTracker().totalApiTokens)
2724
2732
 
2725
2733
  const autoPrompt =
2726
2734
  `## Autonomous Loop — ${sessionId}\n\n` +
@@ -2780,7 +2788,7 @@ const loopCmd: CommandHandler = async (_ctx, args) => {
2780
2788
  if (fullPrompt.length > 0) {
2781
2789
  const sessionId = `autoloop-${Date.now()}`
2782
2790
  const { createAutoloopJournal } = await import('../commands/autoloop-journal.js')
2783
- createAutoloopJournal(sessionId, fullPrompt)
2791
+ createAutoloopJournal(sessionId, fullPrompt, ctx.engine.getUsageTracker().totalApiTokens)
2784
2792
 
2785
2793
  const autoPrompt =
2786
2794
  `## Autonomous Loop — ${sessionId}\n\n` +
@@ -0,0 +1,23 @@
1
+ /**
2
+ * /loop idle-wakeup folding — collapse consecutive noop ticks into a single
3
+ * "💤 idle ×N" line so repeated "nothing to report" wakeups don't spam the UI.
4
+ */
5
+ export function collapseNoopTicks(ticks: Array<{ noop: boolean }>): string {
6
+ const lines: string[] = []
7
+ let i = 0
8
+ while (i < ticks.length) {
9
+ const tick = ticks[i]!
10
+ if (tick.noop) {
11
+ let count = 0
12
+ while (i < ticks.length && ticks[i]!.noop) {
13
+ count++
14
+ i++
15
+ }
16
+ lines.push(`💤 idle ×${count}`)
17
+ } else {
18
+ lines.push('● active')
19
+ i++
20
+ }
21
+ }
22
+ return lines.join('\n')
23
+ }
@@ -0,0 +1,21 @@
1
+ export type ThinkingMode = 'off' | 'minimal' | 'full'
2
+
3
+ /**
4
+ * Render the live reasoning/thinking indicator for the given display mode.
5
+ *
6
+ * - `off` → nothing (hide thinking entirely)
7
+ * - `minimal` → a content-free "💭 <label>…" so long reasoning passes don't
8
+ * look like a stall, without dumping the model's chain-of-thought
9
+ * - `full` → the last 200 chars of the actual thinking text (legacy behavior)
10
+ *
11
+ * Returns null when there is no thinking text to show (or mode is `off`).
12
+ */
13
+ export function formatThinking(
14
+ mode: ThinkingMode,
15
+ thinkingText: string,
16
+ thinkingLabel: string,
17
+ ): string | null {
18
+ if (mode === 'off' || !thinkingText) return null
19
+ if (mode === 'minimal') return `💭 ${thinkingLabel}…`
20
+ return `💭 ${thinkingText.slice(-200)}`
21
+ }