@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/package.json +1 -1
- package/src/commands/autoloop-journal.ts +42 -2
- package/src/config/defaults.ts +3 -0
- package/src/core/engine.ts +65 -2
- package/src/core/permission.ts +45 -0
- package/src/i18n-core/locales/en-US.json +4 -0
- package/src/i18n-core/locales/zh-CN.json +4 -0
- package/src/providers/openai-compat.ts +1 -1
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +11 -0
- package/src/tools/computer/app-launcher.ts +7 -1
- package/src/tools/computer/screenshot.ts +7 -1
- package/src/tools/scheduling/schedule-wakeup.ts +15 -0
- package/src/ui/app.tsx +349 -219
- package/src/ui/command-picker.tsx +20 -13
- package/src/ui/command-token.ts +10 -0
- package/src/ui/commands.ts +22 -7
- package/src/ui/input.tsx +11 -3
- package/src/ui/loop-noop.ts +23 -0
- package/src/ui/thinking.ts +21 -0
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
// ──
|
|
346
|
-
//
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
380
|
-
if (
|
|
381
|
-
const {
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
const
|
|
386
|
-
if (
|
|
387
|
-
|
|
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
|
-
|
|
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
|
-
|
|
457
|
-
|
|
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
|
-
|
|
610
|
-
|
|
611
|
-
|
|
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,
|
|
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
|
-
{
|
|
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={() => {
|