Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions src/cli/commands/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,6 @@ function Root({
model={appProps.model}
reasoningEffort={appProps.reasoningEffort}
system={appProps.system}
rebuildSystem={appProps.rebuildSystem}
transcript={appProps.transcript}
budgetUsd={appProps.budgetUsd}
session={activeSession}
Expand All @@ -247,7 +246,7 @@ function Root({
mcpRuntime={mcpRuntime}
progressSink={progressSink}
startupInfoHints={startupInfoHints}
codeMode={codeMode}
codeMode={appProps.codeMode}
noDashboard={appProps.noDashboard}
openDashboard={appProps.openDashboard}
dashboardPort={appProps.dashboardPort}
Expand Down
17 changes: 16 additions & 1 deletion src/cli/ui/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ import { PlanPanel } from "./PlanPanel.js";
import { PlanRefineInput } from "./PlanRefineInput.js";
import { PlanReviseConfirm, type ReviseChoice } from "./PlanReviseConfirm.js";
import { PlanReviseEditor } from "./PlanReviseEditor.js";
import { PromptInput } from "./PromptInput.js";
import { PromptInput, QueueIndicator } from "./PromptInput.js";
import { SessionPicker } from "./SessionPicker.js";
import { ShellConfirm, type ShellConfirmChoice } from "./ShellConfirm.js";
import { useRenderTrace } from "./render-trace.js";
Expand Down Expand Up @@ -168,6 +168,7 @@ import { useHookList } from "./hooks/useHookList.js";
import { useInputRecall } from "./hooks/useInputRecall.js";
import { useLanguageReload } from "./hooks/useLanguageReload.js";
import { useLoopMode } from "./hooks/useLoopMode.js";
import { useMessageQueue } from "./hooks/useMessageQueue.js";
import { useQuit } from "./hooks/useQuit.js";
import { useScrollback } from "./hooks/useScrollback.js";
import { useToolProgressDisplay } from "./hooks/useToolProgressDisplay.js";
Expand Down Expand Up @@ -618,6 +619,13 @@ function AppInner({
editModeRef,
modeFlash,
} = useEditGate(!!codeMode);
// User steering queue: messages typed while the model is busy.
const messageQueue = useMessageQueue();
const clearMessageQueueRef = useRef(messageQueue.clear);
clearMessageQueueRef.current = messageQueue.clear;
useEffect(() => {
if (!busy) clearMessageQueueRef.current();
}, [busy]);
const setEditModeLive = useCallback(
(mode: EditMode) => {
editModeRef.current = mode;
Expand Down Expand Up @@ -2832,6 +2840,8 @@ function AppInner({
loop.steer(text);
log.pushInfo(t("app.steerInjected"));
log.pushInfo(text, "ghost");
loop.queueMessage(text);
messageQueue.enqueue(text);
}
return;
}
Expand Down Expand Up @@ -3459,6 +3469,8 @@ function AppInner({
}
if (ev.role === "status") {
setStatusLine(ev.content);
} else if (ev.role === "user.queued") {
log.pushUser(ev.content);
} else if (ev.role === "assistant_delta") {
if (ev.content) contentBuf.current += ev.content;
if (ev.reasoningDelta) reasoningBuf.current += ev.reasoningDelta;
Expand Down Expand Up @@ -3693,6 +3705,7 @@ function AppInner({
mcpRuntime,
pushHistory,
resetCursor,
messageQueue.enqueue,
liveMcpServers,
generateCurrentSessionTitle,
switchWorkspaceRoot,
Expand Down Expand Up @@ -4525,6 +4538,7 @@ function AppInner({
setInput={setInput}
busy={busy}
steerBusy={busy}
queueMessages={messageQueue.queue}
onSubmit={handleSubmit}
onHistoryPrev={handleHistoryPrev}
onHistoryNext={handleHistoryNext}
Expand Down Expand Up @@ -4831,6 +4845,7 @@ function AppInner({
setInput={setInput}
busy={busy}
steerBusy={busy}
queueMessages={messageQueue.queue}
onSubmit={handleSubmit}
onHistoryPrev={handleHistoryPrev}
onHistoryNext={handleHistoryNext}
Expand Down
5 changes: 4 additions & 1 deletion src/cli/ui/ComposerArea.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import type { JobRegistry } from "../../tools/jobs.js";
import { useRenderTrace } from "./render-trace.js";

import { AtMentionSuggestions } from "./AtMentionSuggestions.js";
import { PromptInput } from "./PromptInput.js";
import { PromptInput, QueueIndicator } from "./PromptInput.js";
import { ShortcutsHelpModal } from "./ShortcutsHelpModal.js";
import type { SlashArgPickerProps } from "./SlashArgPicker.js";
import { SlashArgPicker } from "./SlashArgPicker.js";
Expand Down Expand Up @@ -49,6 +49,7 @@ export interface ComposerAreaProps {
setInput: (next: string) => void;
busy: boolean;
steerBusy?: boolean;
queueMessages: { text: string; enqueuedAt: number }[];
onSubmit: (raw: string) => Promise<void>;
onHistoryPrev: () => void;
onHistoryNext: () => void;
Expand Down Expand Up @@ -93,6 +94,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
setInput,
busy,
steerBusy,
queueMessages,
onSubmit,
onHistoryPrev,
onHistoryNext,
Expand Down Expand Up @@ -141,6 +143,7 @@ export const ComposerArea: React.FC<ComposerAreaProps> = React.memo(
) : null}
</Box>
{showShortcuts ? <ShortcutsHelpModal /> : null}
<QueueIndicator messages={queueMessages} />
<PromptInput
value={input}
onChange={setInput}
Expand Down
3 changes: 2 additions & 1 deletion src/cli/ui/PlanPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@
*/

import { Box, type Color, Text } from "ink";
import React, { useMemo, useState } from "react";
import type React from "react";
import { useMemo, useState } from "react";
import { t } from "../../i18n/index.js";
import type { PlanStep, StepCompletion } from "../../tools/plan.js";
import type { CheckpointChoice } from "./PlanCheckpointConfirm.js";
Expand Down
40 changes: 40 additions & 0 deletions src/cli/ui/PromptInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,46 @@ export function shouldInlinePaste(content: string): boolean {
return !content.includes("\n") && content.length <= INLINE_PASTE_THRESHOLD;
}

// ── QueueIndicator ────────────────────────────────────────────────────

export interface QueueIndicatorProps {
/** Either plain strings (for simple count+preview) or objects with timestamps (for timer). */
messages: string[] | ReadonlyArray<{ text: string; enqueuedAt: number }>;
/** Remaining ms before auto-dismiss; 0 or undefined means no timer shown. */
remainingMs?: number;
}

/** Compact row shown above the prompt input when the user has queued steering messages
* while the model is busy. Shows count + last message preview + optional Esc hint. */
export function QueueIndicator({
messages,
remainingMs,
}: QueueIndicatorProps): React.ReactElement | null {
if (messages.length === 0) return null;

const count = messages.length;
const lastRaw = messages[messages.length - 1]!;
const lastText = typeof lastRaw === "string" ? lastRaw : lastRaw.text;
const preview = lastText.length > 60 ? `${lastText.slice(0, 57)}…` : lastText;
const timer =
typeof remainingMs === "number" && remainingMs > 0
? ` · ${Math.ceil(remainingMs / 1000)}s`
: "";
const hint = " · esc to remove";

return (
<Box>
<Text color={FG.faint}>
⏳ QUEUE ({count}) — {preview}
{timer}
{hint}
</Text>
</Box>
);
}

// ── PromptInputProps ─────────────────────────────────────────────────

export interface PromptInputProps {
value: string;
onChange: (v: string) => void;
Expand Down
138 changes: 138 additions & 0 deletions src/cli/ui/hooks/useMessageQueue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/** Queue for user steering messages while busy — tracks, auto-dismisses, restores. App.tsx consumes it. */

import { useCallback, useEffect, useRef, useState } from "react";

// Types

export interface QueuedMessage {
text: string;
enqueuedAt: number;
}

// Constants

/** How long a queued message sits before auto-dismissing (matches edit-undo convention). */
export const QUEUE_DISMISS_MS = 5_000;

// Pure helpers (testable without React)

/** Add a message to the queue. Rejects empty/whitespace. Returns the new queue. */
export function addMessage(
queue: QueuedMessage[],
text: string,
now: number = Date.now(),
): { queue: QueuedMessage[]; rejected: boolean } {
if (!text.trim()) return { queue, rejected: true };
return {
queue: [...queue, { text: text.trim(), enqueuedAt: now }],
rejected: false,
};
}

/** Remove (pop) the last message from the queue. Returns it + the new queue, or null if empty. */
export function popMessage(
queue: QueuedMessage[],
): { message: QueuedMessage; queue: QueuedMessage[] } | null {
if (queue.length === 0) return null;
const last = queue[queue.length - 1]!;
return { message: last, queue: queue.slice(0, -1) };
}

/** Remove all messages from the queue. */
export function clearQueue(): QueuedMessage[] {
return [];
}

/** Filter out messages that have expired based on `since` timestamp. */
export function expireMessages(
queue: QueuedMessage[],
ttlMs: number = QUEUE_DISMISS_MS,
now: number = Date.now(),
): QueuedMessage[] {
return queue.filter((m) => now - m.enqueuedAt < ttlMs);
}

/** Time remaining before the newest message expires (0 if queue empty). */
export function remainingMs(
queue: QueuedMessage[],
ttlMs: number = QUEUE_DISMISS_MS,
now: number = Date.now(),
): number {
if (queue.length === 0) return 0;
const latest = queue[queue.length - 1]!;
return Math.max(0, ttlMs - (now - latest.enqueuedAt));
}

// React hook

export function useMessageQueue(ttlMs: number = QUEUE_DISMISS_MS): {
/** Current queued messages (not yet consumed by the loop). */
queue: QueuedMessage[];
/** Number of queued messages (convenience). */
count: number;
/** Add a message to the queue. Returns true if accepted, false if rejected (empty). */
enqueue: (text: string) => boolean;
/** Pop the last message off the queue (restore to input buffer). */
dequeue: () => string | null;
/** Clear all queued messages. */
clear: () => void;
} {
const [queue, setQueue] = useState<QueuedMessage[]>([]);
const expiryRef = useRef<ReturnType<typeof setTimeout> | null>(null);

// Auto-dismiss timer: when queue transitions from empty → non-empty,
// start a timer that removes the newest message after ttlMs.
useEffect(() => {
if (queue.length === 0) {
if (expiryRef.current) {
clearTimeout(expiryRef.current);
expiryRef.current = null;
}
return;
}
// Schedule expiry for the latest message
const latest = queue[queue.length - 1]!;
const elapsed = Date.now() - latest.enqueuedAt;
const remaining = Math.max(0, ttlMs - elapsed);
if (remaining <= 0) {
// Already expired — pop it
setQueue((prev) => expireMessages(prev, ttlMs));
return;
}
expiryRef.current = setTimeout(() => {
setQueue((prev) => {
const expired = expireMessages(prev, ttlMs);
// If nothing was removed, nothing to do
if (expired.length === prev.length) return prev;
// The latest message expired: return the filtered queue
return expired;
});
}, remaining);
return () => {
if (expiryRef.current) clearTimeout(expiryRef.current);
};
}, [queue, ttlMs]);

const enqueue = useCallback(
(text: string): boolean => {
const { queue: next, rejected } = addMessage(queue, text);
if (rejected) return false;
setQueue(next);
return true;
},
[queue],
);

const dequeue = useCallback((): string | null => {
const result = popMessage(queue);
if (!result) return null;
setQueue(result.queue);
return result.message.text;
}, [queue]);

const clear = useCallback(() => {
setQueue([]);
}, []);

return { queue, count: queue.length, enqueue, dequeue, clear };
}
3 changes: 3 additions & 0 deletions src/core/eventize.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ export class Eventizer {
case "status":
out.push(this.statusEvent(ev.turn, ev.content));
break;
case "user.queued":
out.push(this.emitUserMessage(ev.turn, ev.content));
break;
// `done` / `branch_*` intentionally drop — no kernel-level event.
default:
break;
Expand Down
Loading
Loading