@miphamai/cli 0.59.0 → 0.60.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/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
389
+ updated.push({ role: 'system', content: folded })
455
390
  }
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
- }
476
- }
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
@@ -606,10 +446,17 @@ export function App({
606
446
  clearTimeout(thinkingTimerRef.current)
607
447
  thinkingTimerRef.current = null
608
448
  }
609
- setMessages((prev) => [
610
- ...prev,
611
- { role: 'system' as const, content: `💭 ${thought}` },
612
- ])
449
+ const thinkingLine = formatThinking(
450
+ config.showThinking ?? 'off',
451
+ thought,
452
+ t('ui.loading.thinking'),
453
+ )
454
+ if (thinkingLine) {
455
+ setMessages((prev) => [
456
+ ...prev,
457
+ { role: 'system' as const, content: thinkingLine },
458
+ ])
459
+ }
613
460
  }
614
461
  turnContent = chunk.content
615
462
  isNewTurn = false
@@ -638,7 +485,6 @@ export function App({
638
485
  }, 60)
639
486
  }
640
487
  }
641
- assistantContent += chunk.content
642
488
  }
643
489
 
644
490
  if (chunk.type === 'tool_use' && chunk.toolUse) {
@@ -806,8 +652,284 @@ export function App({
806
652
  // Final sync of background agents after the turn completes
807
653
  syncBgAgents()
808
654
  }
655
+
656
+ // End-of-loop-turn accounting: log iteration + token delta, and stop at the
657
+ // max-iteration guard. `input` is the loop id for /loop auto (ScheduleWakeup
658
+ // re-invokes runTurn(loopId, 'loop')); fixed-interval loops have no journal and skip.
659
+ if (source === 'loop' && 'hasPendingWakeup' in engine) {
660
+ const journal = readAutoloopJournal(input)
661
+ if (journal && journal.status === 'active') {
662
+ const delta = engine.getUsageTracker().totalApiTokens - loopStartTokens
663
+ recordLoopTurn(input, assistantContent.slice(0, 200), delta)
664
+ // max-iteration guard: recordLoopTurn flips the journal to 'stopped' when
665
+ // iterations hit maxIterations. Stop re-invocation so the loop doesn't wake
666
+ // again after hitting the cap.
667
+ const after = readAutoloopJournal(input)
668
+ if (after && after.status !== 'active') {
669
+ if (sessionId) cancelAllSessionTimers(sessionId)
670
+ engine.clearWakeupQueue()
671
+ }
672
+ }
673
+ }
674
+
675
+ // Turn finished — drain any /loop wakeup queued while we were running.
676
+ drainLoopQueueRef.current?.(turnId)
677
+ },
678
+ [engine, syncBgAgents, config, t],
679
+ )
680
+
681
+ // Ref-held drain breaks the runTurn ↔ drain circular useCallback dependency: runTurn
682
+ // calls drainLoopQueueRef.current (a stable ref object), and the drain closure captures
683
+ // the latest runTurn. Reassigned each render so it never holds a stale runTurn.
684
+ const drainLoopQueueRef = useRef<((turnId: number) => Promise<void>) | null>(null)
685
+ drainLoopQueueRef.current = async (turnId: number) => {
686
+ // User turn wins: if a user submitted meanwhile, turnId advanced and this loop wakeup
687
+ // yields (the next timer fire re-enqueues). Spec §六 — do not force it to run anyway.
688
+ if (turnIdRef.current !== turnId) return
689
+ // RemoteEngine (remote attach mode) has no wakeup queue — /loop is CLI-local.
690
+ if (!('hasPendingWakeup' in engine)) return
691
+ const next = engine.hasPendingWakeup() ? engine.dequeueWakeup() : null
692
+ if (next) await runTurn(next.prompt, 'loop', undefined, next.noop)
693
+ }
694
+
695
+ // ── /loop idle-drain trigger ──
696
+ // The ScheduleWakeup timer fires 60–3600s after the turn that scheduled it, while
697
+ // the engine is idle. At that moment enqueueWakeup only mutates the queue (no React
698
+ // state change, no poller), so the queue would sit undrained forever. Subscribe the
699
+ // engine's onEnqueue callback to a state bump; the idle-drain effect below then
700
+ // drains the queue and re-invokes runTurn.
701
+ useEffect(() => {
702
+ if (!('setOnWakeupEnqueued' in engine)) return
703
+ const qe = engine // narrow to QueryEngine for the cleanup closure
704
+ qe.setOnWakeupEnqueued(() => setWakeupTick((t) => t + 1))
705
+ return () => qe.setOnWakeupEnqueued(null)
706
+ }, [engine])
707
+
708
+ // When a wakeup is enqueued and the engine is idle, drain it now (the running turn's
709
+ // end-drain handles the busy case). `wakeupTick === 0` skips the mount-time run.
710
+ useEffect(() => {
711
+ if (wakeupTick === 0) return
712
+ if (isLoading) return
713
+ drainLoopQueueRef.current?.(turnIdRef.current)
714
+ }, [wakeupTick, isLoading])
715
+
716
+ const handleSubmit = useCallback(
717
+ async (input: string) => {
718
+ if (!input.trim()) return
719
+
720
+ // ── @mention: direct cross-session message — only when the name resolves
721
+ // to a live session; otherwise fall through to normal AI processing so a
722
+ // leading `@word` (e.g. "@bob please review") isn't hijacked as a send. ──
723
+ const mention = parseMention(input)
724
+ if (mention && resolveRecipientSession(discoverSessions(), mention.name).session) {
725
+ if (!mention.message) {
726
+ setMessages((prev) => [
727
+ ...prev,
728
+ { role: 'user', content: input },
729
+ { role: 'system', content: 'Usage: @session-name <message>' },
730
+ ])
731
+ return
732
+ }
733
+ const mentionSummary =
734
+ mention.message.length > 50 ? mention.message.slice(0, 47) + '...' : mention.message
735
+ const result = await getMessageRouter().route(
736
+ sessionId || 'main',
737
+ mention.name,
738
+ mentionSummary,
739
+ mention.message,
740
+ )
741
+ setMessages((prev) => [
742
+ ...prev,
743
+ { role: 'user', content: input },
744
+ {
745
+ role: 'system',
746
+ content: result.success
747
+ ? `── Message sent to ${mention.name} ──${result.messageId ? `\nID: ${result.messageId}` : ''}`
748
+ : `❌ Failed to send to ${mention.name}: ${result.error}`,
749
+ },
750
+ ])
751
+ return
752
+ }
753
+
754
+ // ── Slash command dispatch ──
755
+ if (looksLikeSlashCommand(input)) {
756
+ const { command, args } = parseSlashCommand(input)
757
+
758
+ // /switch takes args, handled separately
759
+ if (command === '/switch') {
760
+ const result = await handleSwitch(mkCtx(), args)
761
+ if (result.needsApiKey) {
762
+ setApiKeyPrompt(result.needsApiKey)
763
+ setApiKeyInput('')
764
+ setMessages((prev) => [
765
+ ...prev,
766
+ { role: 'user', content: input },
767
+ { role: 'system', content: result.content },
768
+ ])
769
+ } else {
770
+ setMessages((prev) => [
771
+ ...prev,
772
+ { role: 'user', content: input },
773
+ { role: 'system', content: result.content },
774
+ ])
775
+ if (result.nextProvider) setProviderId(result.nextProvider)
776
+ if (result.nextModel) setModelId(result.nextModel)
777
+ }
778
+ if (result.exit) process.exit(0)
779
+ return
780
+ }
781
+
782
+ // /pick → open interactive model picker
783
+ if (command === '/pick' || command === '/model-picker') {
784
+ setPickerOpen(true)
785
+ return
786
+ }
787
+
788
+ // /quit and /exit are special
789
+ if (command === '/exit' || command === '/quit') {
790
+ process.exit(0)
791
+ }
792
+
793
+ // /focus toggle
794
+ if (command === '/focus') {
795
+ const nextFocus = !focusMode
796
+ setFocusMode(nextFocus)
797
+ setMessages((prev) => [
798
+ ...prev,
799
+ { role: 'user', content: input },
800
+ {
801
+ role: 'system',
802
+ content: nextFocus
803
+ ? '✓ Focus mode ON — showing only the most recent exchange. Type /focus again to show all.'
804
+ : '✓ Focus mode OFF — showing all messages.',
805
+ },
806
+ ])
807
+ return
808
+ }
809
+
810
+ let forwardToAI: string | undefined
811
+
812
+ const handler = getCommand(command)
813
+ if (handler) {
814
+ const result = await handler(mkCtx(), args)
815
+ forwardToAI = result.forwardToAI
816
+ // Handle API key prompt from command result
817
+ if (result.needsApiKey) {
818
+ setApiKeyPrompt(result.needsApiKey)
819
+ setApiKeyInput('')
820
+ }
821
+ setMessages((prev) => {
822
+ const next: ChatMessage[] = [...prev, { role: 'user', content: input }]
823
+ if (result.content) next.push({ role: 'system', content: result.content })
824
+ return next
825
+ })
826
+ if (result.needsApiKey) {
827
+ // Don't process nextProvider/model when waiting for API key
828
+ if (result.exit) process.exit(0)
829
+ return
830
+ }
831
+ if (result.clearMessages) setMessages([])
832
+ if (result.nextProvider) setProviderId(result.nextProvider)
833
+ if (result.nextModel) setModelId(result.nextModel)
834
+ if (result.exit) process.exit(0)
835
+ if (result.forwardedMessages && result.forwardedMessages.length > 0) {
836
+ const restored: ChatMessage[] = result.forwardedMessages.map((msg) => ({
837
+ role: msg.role,
838
+ content: typeof msg.content === 'string' ? msg.content : JSON.stringify(msg.content),
839
+ }))
840
+ if (result.resumeWarning) {
841
+ // Fix 7: Session isolation — add a visual separator between current session
842
+ // and restored history to prevent accidental leakage/confusion
843
+ const separator: ChatMessage = {
844
+ role: 'assistant' as const,
845
+ content: '── Restored session history below ──',
846
+ }
847
+ setMessages((prev) => [...prev, separator, ...restored])
848
+ } else {
849
+ setMessages((prev) => [...prev, ...restored])
850
+ }
851
+ }
852
+ if (result.copyContent) {
853
+ // Copy to clipboard via pbcopy (macOS) or clip (Windows)
854
+ try {
855
+ const { execSync } = await import('node:child_process')
856
+ if (process.platform === 'darwin') {
857
+ execSync('pbcopy', { input: result.copyContent })
858
+ } else if (process.platform === 'win32') {
859
+ execSync('clip', { input: result.copyContent })
860
+ }
861
+ // Linux: xclip or wl-copy not attempted to avoid dependency issues
862
+ } catch {
863
+ // Silent fail — content is still displayed
864
+ }
865
+ }
866
+ }
867
+
868
+ // Bridge: if command set forwardToAI, route the message to AI processing
869
+ if (forwardToAI) {
870
+ input = forwardToAI
871
+ // fall through to normal AI processing below
872
+ } else {
873
+ // No handler matched or handler didn't request AI routing — stop here
874
+ return
875
+ }
876
+ }
877
+
878
+ // ── Emotion detection: adjust behavior based on user's emotional state ──
879
+ // Uses regex heuristics (zero-latency) to detect frustration/impatience/confusion.
880
+ // When frustrated, prepends a terseness instruction to the user input so the
881
+ // AI model skips explanations and gets straight to the fix.
882
+ let emotionPrefix = ''
883
+ try {
884
+ const { EmotionDetector } = await import('../core/emotion-detector.js')
885
+ const detector = new EmotionDetector()
886
+ const result = detector.detect(input)
887
+ if (result.emotion === 'frustrated' || result.emotion === 'impatient') {
888
+ 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`
889
+ } else if (result.emotion === 'confused') {
890
+ emotionPrefix = `[SYSTEM NOTE: The user seems confused. Explain more thoroughly, break down complex steps, and offer clarifying questions rather than assuming understanding.]\n\n`
891
+ }
892
+ } catch {
893
+ // Emotion detection is non-critical — fail silently
894
+ }
895
+
896
+ // ── Normal message processing (AI chat) ──
897
+ // First user message: auto-name the session if it still carries the
898
+ // default cwd-basename name (respecting any manual /rename).
899
+ if (
900
+ engine
901
+ .getContext()
902
+ .getMessages()
903
+ .every((m) => m.role !== 'user')
904
+ ) {
905
+ const currentName = discoverSessions().find((s) => s.id === sessionId)?.name
906
+ if (isDefaultSessionName(currentName, process.cwd())) {
907
+ const title = deriveSessionTitle(input)
908
+ if (title && sessionId) renameActiveSession(sessionId, title)
909
+ }
910
+ }
911
+
912
+ setMessages((prev) => [...prev, { role: 'user', content: input }])
913
+ setIsLoading(true)
914
+
915
+ // Start agent progress indicator immediately for ALL processing
916
+ const progressStart = Date.now()
917
+ const progress: AgentProgress = {
918
+ name: '',
919
+ description: '',
920
+ startTime: progressStart,
921
+ tokensUsed: 0,
922
+ }
923
+ agentProgressRef.current = progress
924
+ setAgentProgress(progress)
925
+ setAgentTick((t) => t + 1)
926
+
927
+ const controller = new AbortController()
928
+ abortRef.current = controller
929
+
930
+ await runTurn(emotionPrefix ? emotionPrefix + input : input, 'user', controller)
809
931
  },
810
- [engine, mkCtx, syncBgAgents],
932
+ [engine, mkCtx, runTurn],
811
933
  )
812
934
 
813
935
  useInput((_input, key) => {
@@ -902,7 +1024,14 @@ export function App({
902
1024
  <>
903
1025
  {/* Chat panel */}
904
1026
  <ChatPanel messages={messages} focusMode={focusMode} />
905
- {thinkingText ? <Text dimColor>💭 {thinkingText.slice(-200)}</Text> : null}
1027
+ {(() => {
1028
+ const indicator = formatThinking(
1029
+ config.showThinking ?? 'off',
1030
+ thinkingText,
1031
+ t('ui.loading.thinking'),
1032
+ )
1033
+ return indicator ? <Text dimColor>{indicator}</Text> : null
1034
+ })()}
906
1035
 
907
1036
  {/* Input with separator lines */}
908
1037
  {pickerOpen ? (
@@ -941,6 +1070,7 @@ export function App({
941
1070
  <InputBar
942
1071
  onSubmit={handleSubmit}
943
1072
  isLoading={isLoading}
1073
+ showCommandPicker={config.showCommandPicker ?? false}
944
1074
  onTogglePicker={() => setPickerOpen((prev) => !prev)}
945
1075
  onToggleFocus={() => setFocusMode((prev) => !prev)}
946
1076
  onToggleExpand={() => {