diff --git a/src/agent/loop.ts b/src/agent/loop.ts
index 1422e8e..3e2afff 100644
--- a/src/agent/loop.ts
+++ b/src/agent/loop.ts
@@ -629,6 +629,7 @@ export async function interactiveSession(
// Session persistence — reuse existing session ID when resuming, else create new
const sessionId = config.resumeSessionId || createSessionId();
+ config.onSessionStart?.(sessionId);
let turnCount = 0;
// Resume: hydrate history from the saved JSONL transcript.
diff --git a/src/agent/types.ts b/src/agent/types.ts
index 806187a..3c3ea40 100644
--- a/src/agent/types.ts
+++ b/src/agent/types.ts
@@ -199,6 +199,8 @@ export interface AgentConfig {
baseModel?: string;
/** Resume an existing session by ID — loads prior history and keeps appending to the same JSONL */
resumeSessionId?: string;
+ /** Notify callers of the concrete session ID once created/resolved. */
+ onSessionStart?: (sessionId: string) => void;
/**
* Optional channel tag persisted to SessionMeta. Lets non-CLI drivers
* (Telegram bot, Discord bot, future ingresses) find their own sessions
diff --git a/src/commands/start.ts b/src/commands/start.ts
index bc4f41d..17a1a35 100644
--- a/src/commands/start.ts
+++ b/src/commands/start.ts
@@ -484,6 +484,8 @@ async function runWithInkUI(
agentConfig.onAskUser = (question, options) =>
ui.requestAskUser(question, options);
agentConfig.onModelChange = (model) => ui.updateModel(model);
+ let activeSessionId = agentConfig.resumeSessionId;
+ agentConfig.onSessionStart = (sessionId) => { activeSessionId = sessionId; };
// Wire up background balance fetch to UI
onBalanceReady?.((bal) => ui.updateBalance(bal));
@@ -562,6 +564,21 @@ async function runWithInkUI(
}
} catch { /* stats unavailable */ }
+ let savedSessionId: string | undefined;
+ if (activeSessionId) {
+ try {
+ const { loadSessionMeta } = await import('../session/storage.js');
+ const meta = loadSessionMeta(activeSessionId);
+ if ((meta?.messageCount ?? 0) > 0) savedSessionId = activeSessionId;
+ } catch { /* session hint is best-effort */ }
+ }
+
+ if (savedSessionId) {
+ console.log(chalk.dim(`\n Session: ${savedSessionId}`));
+ console.log(chalk.dim(` Resume: franklin --resume ${savedSessionId}`));
+ console.log(chalk.dim(' Latest: franklin --continue'));
+ }
+
console.log(chalk.dim('\nGoodbye.\n'));
}
diff --git a/src/ui/app.tsx b/src/ui/app.tsx
index 5923626..6aec073 100644
--- a/src/ui/app.tsx
+++ b/src/ui/app.tsx
@@ -23,9 +23,258 @@ import { resolveAskUserAnswer } from './ask-user-answer.js';
// ─── Full-width input box ──────────────────────────────────────────────────
+const BRACKETED_PASTE_START = '[200~';
+const BRACKETED_PASTE_END = '[201~';
+const ENABLE_BRACKETED_PASTE = '\x1b[?2004h';
+const DISABLE_BRACKETED_PASTE = '\x1b[?2004l';
+const USER_PROMPT_COLOR = '#FFD700';
+const PASTE_BLOCK_START = '\uE000PASTE:';
+const PASTE_BLOCK_END = ':PASTE\uE001';
+
const DISABLE_AUTO_WRAP = '\x1b[?7l';
const ENABLE_AUTO_WRAP = '\x1b[?7h';
+function stripPasteMarkers(input: string): string {
+ return input
+ .replaceAll(BRACKETED_PASTE_START, '')
+ .replaceAll(BRACKETED_PASTE_END, '');
+}
+
+function normalizeInputNewlines(input: string): string {
+ return input.replace(/\r\n|\r|\n/g, '\n').replace(/\x1b/g, '');
+}
+
+function shouldSummarizeInput(value: string): boolean {
+ return value.includes('\n') || value.length > 240;
+}
+
+interface PasteBlock {
+ start: number;
+ end: number;
+ content: string;
+}
+
+function encodePasteBlock(content: string): string {
+ return `${PASTE_BLOCK_START}${Buffer.from(content, 'utf8').toString('base64')}${PASTE_BLOCK_END}`;
+}
+
+function decodePasteBlock(token: string): string {
+ if (!token.startsWith(PASTE_BLOCK_START) || !token.endsWith(PASTE_BLOCK_END)) return token;
+ const payload = token.slice(PASTE_BLOCK_START.length, -PASTE_BLOCK_END.length);
+ try {
+ return Buffer.from(payload, 'base64').toString('utf8');
+ } catch {
+ return token;
+ }
+}
+
+function findPasteBlocks(value: string): PasteBlock[] {
+ const blocks: PasteBlock[] = [];
+ let searchFrom = 0;
+
+ while (searchFrom < value.length) {
+ const start = value.indexOf(PASTE_BLOCK_START, searchFrom);
+ if (start < 0) break;
+ const endMarker = value.indexOf(PASTE_BLOCK_END, start + PASTE_BLOCK_START.length);
+ if (endMarker < 0) break;
+ const end = endMarker + PASTE_BLOCK_END.length;
+ blocks.push({ start, end, content: decodePasteBlock(value.slice(start, end)) });
+ searchFrom = end;
+ }
+
+ return blocks;
+}
+
+function decodePromptValue(value: string): string {
+ let decoded = '';
+ let cursor = 0;
+
+ for (const block of findPasteBlocks(value)) {
+ decoded += value.slice(cursor, block.start) + block.content;
+ cursor = block.end;
+ }
+
+ return decoded + value.slice(cursor);
+}
+
+function pasteSummary(content: string): string {
+ const lines = content.length === 0 ? 0 : content.split('\n').length;
+ const lineLabel = lines > 1 ? `~${lines} lines` : '~1 line';
+ return `[Pasted ${lineLabel}]`;
+}
+
+function renderInputValue(value: string, cursorOffset: number, focused: boolean): string {
+ const blocks = findPasteBlocks(value);
+ if (blocks.length > 0) {
+ let rendered = '';
+ let cursor = 0;
+
+ for (const block of blocks) {
+ rendered += renderPlainInputSegment(value.slice(cursor, block.start), cursorOffset - cursor, focused && cursorOffset >= cursor && cursorOffset <= block.start);
+ if (focused && cursorOffset === block.start) rendered += chalk.inverse(' ');
+ rendered += chalk.hex(USER_PROMPT_COLOR).bold(pasteSummary(block.content));
+ if (focused && cursorOffset === block.end) rendered += chalk.inverse(' ');
+ cursor = block.end;
+ }
+
+ rendered += renderPlainInputSegment(value.slice(cursor), cursorOffset - cursor, focused && cursorOffset >= cursor);
+ return rendered || (focused ? chalk.inverse(' ') : '');
+ }
+
+ return renderPlainInputSegment(value, cursorOffset, focused);
+}
+
+function renderPlainInputSegment(value: string, cursorOffset: number, focused: boolean): string {
+ const displayValue = value.replace(/\r\n|\r|\n/g, ' ');
+ if (!focused) return displayValue;
+
+ const safeCursor = Math.max(0, Math.min(cursorOffset, displayValue.length));
+ if (displayValue.length === 0) return chalk.inverse(' ');
+
+ const before = displayValue.slice(0, safeCursor);
+ const current = displayValue[safeCursor] ?? ' ';
+ const after = displayValue.slice(safeCursor + (safeCursor < displayValue.length ? 1 : 0));
+ return before + chalk.inverse(current) + after;
+}
+
+function PromptTextInput({ value, onChange, onSubmit, placeholder = '', focus = true }: {
+ value: string;
+ onChange: (value: string) => void;
+ onSubmit: (value: string) => void;
+ placeholder?: string;
+ focus?: boolean;
+}) {
+ const [cursorOffset, setCursorOffset] = useState(value.length);
+ const valueRef = useRef(value);
+ const cursorOffsetRef = useRef(value.length);
+ const pasteActiveRef = useRef(false);
+ const pasteBufferRef = useRef('');
+
+ useEffect(() => {
+ valueRef.current = value;
+ setCursorOffset((offset) => {
+ const nextOffset = Math.min(offset, value.length);
+ cursorOffsetRef.current = nextOffset;
+ return nextOffset;
+ });
+ }, [value]);
+
+ const updateValue = useCallback((nextValue: string, nextCursorOffset: number) => {
+ valueRef.current = nextValue;
+ cursorOffsetRef.current = Math.max(0, Math.min(nextCursorOffset, nextValue.length));
+ onChange(nextValue);
+ setCursorOffset(cursorOffsetRef.current);
+ }, [onChange]);
+
+ useInput((input, key) => {
+ if (!focus) return;
+
+ const currentValue = valueRef.current;
+ const currentCursorOffset = cursorOffsetRef.current;
+ const pasteBlockBeforeCursor = findPasteBlocks(currentValue).find((block) => block.end === currentCursorOffset);
+ const pasteBlockAfterCursor = findPasteBlocks(currentValue).find((block) => block.start === currentCursorOffset);
+
+ const hasPasteStart = input.includes(BRACKETED_PASTE_START);
+ const hasPasteEnd = input.includes(BRACKETED_PASTE_END);
+ const isPasting = pasteActiveRef.current || hasPasteStart;
+
+ if (hasPasteStart && !pasteActiveRef.current) {
+ pasteActiveRef.current = true;
+ pasteBufferRef.current = '';
+ }
+
+ if (key.return && !isPasting) {
+ onSubmit(decodePromptValue(currentValue));
+ return;
+ }
+
+ if (key.home || (key.ctrl && input === 'a')) {
+ cursorOffsetRef.current = 0;
+ setCursorOffset(0);
+ return;
+ }
+
+ if (key.end || (key.ctrl && input === 'e')) {
+ cursorOffsetRef.current = currentValue.length;
+ setCursorOffset(currentValue.length);
+ return;
+ }
+
+ if (key.leftArrow) {
+ const previousBlock = findPasteBlocks(currentValue).find((block) => block.end === currentCursorOffset);
+ const nextOffset = previousBlock ? previousBlock.start : Math.max(0, currentCursorOffset - 1);
+ cursorOffsetRef.current = nextOffset;
+ setCursorOffset(nextOffset);
+ return;
+ }
+
+ if (key.rightArrow) {
+ const nextBlock = findPasteBlocks(currentValue).find((block) => block.start === currentCursorOffset);
+ const nextOffset = nextBlock ? nextBlock.end : Math.min(currentValue.length, currentCursorOffset + 1);
+ cursorOffsetRef.current = nextOffset;
+ setCursorOffset(nextOffset);
+ return;
+ }
+
+ if (key.backspace || key.delete) {
+ if (key.backspace && pasteBlockBeforeCursor) {
+ updateValue(currentValue.slice(0, pasteBlockBeforeCursor.start) + currentValue.slice(pasteBlockBeforeCursor.end), pasteBlockBeforeCursor.start);
+ return;
+ }
+
+ if (key.delete && pasteBlockAfterCursor) {
+ updateValue(currentValue.slice(0, pasteBlockAfterCursor.start) + currentValue.slice(pasteBlockAfterCursor.end), pasteBlockAfterCursor.start);
+ return;
+ }
+
+ if (currentCursorOffset > 0) {
+ updateValue(
+ currentValue.slice(0, currentCursorOffset - 1) + currentValue.slice(currentCursorOffset),
+ currentCursorOffset - 1,
+ );
+ }
+ return;
+ }
+
+ if (key.upArrow || key.downArrow || key.tab || key.ctrl || key.meta) return;
+
+ let text = normalizeInputNewlines(stripPasteMarkers(input));
+ if (key.return && isPasting) text = '\n';
+
+ if (isPasting) {
+ pasteBufferRef.current += text;
+
+ if (!hasPasteEnd) return;
+
+ text = encodePasteBlock(pasteBufferRef.current);
+ pasteBufferRef.current = '';
+ pasteActiveRef.current = false;
+ }
+
+ if (!text) {
+ if (hasPasteEnd) pasteActiveRef.current = false;
+ return;
+ }
+
+ updateValue(
+ currentValue.slice(0, currentCursorOffset) + text + currentValue.slice(currentCursorOffset),
+ currentCursorOffset + text.length,
+ );
+
+ if (hasPasteEnd) pasteActiveRef.current = false;
+ }, { isActive: focus });
+
+ const rendered = value.length > 0
+ ? renderInputValue(value, cursorOffset, focus)
+ : (focus && placeholder ? chalk.inverse(placeholder[0]) + chalk.grey(placeholder.slice(1)) : chalk.grey(placeholder));
+
+ return {rendered};
+}
+
+function formatUserPromptForDisplay(value: string): string {
+ return `❯ ${decodePromptValue(value)}`;
+}
+
function disableTerminalAutoWrap(): (() => void) | undefined {
if (!process.stdout.isTTY) return undefined;
@@ -45,6 +294,25 @@ function disableTerminalAutoWrap(): (() => void) | undefined {
};
}
+function enableBracketedPaste(): (() => void) | undefined {
+ if (!process.stdout.isTTY) return undefined;
+
+ let restored = false;
+ const restore = () => {
+ if (restored || !process.stdout.writable) return;
+ restored = true;
+ process.stdout.write(DISABLE_BRACKETED_PASTE);
+ };
+
+ process.stdout.write(ENABLE_BRACKETED_PASTE);
+ process.once('exit', restore);
+
+ return () => {
+ process.off('exit', restore);
+ restore();
+ };
+}
+
// Subscribe to terminal resize so React re-renders with fresh dimensions.
// Without this, useStdout() returns a stable ref and children that read
// stdout.columns on each render still need React to re-execute them — which
@@ -138,7 +406,7 @@ function InputBox({ input, setInput, onSubmit, model, balance, chain, walletTail
onModeChange={onVimModeChange}
/>
) : (
- (undefined);
const turnCtxPctRef = useRef(undefined);
const queuedInputsRef = useRef([]);
+ const lastCtrlCRef = useRef(0);
// Keep refs in sync so memoized event handlers can read current values
streamTextRef.current = streamText;
@@ -418,6 +687,25 @@ function RunCodeApp({
}
}, []);
+ const requestExit = useCallback((abortTurn = false) => {
+ if (abortTurn) onAbort();
+ onExit();
+ exit();
+ }, [onAbort, onExit, exit]);
+
+ useInput((ch, key) => {
+ if (!(key.ctrl && ch === 'c')) return;
+
+ const now = Date.now();
+ if (now - lastCtrlCRef.current < 2000) {
+ requestExit(true);
+ return;
+ }
+
+ lastCtrlCRef.current = now;
+ showStatus('Press Ctrl+C again to exit', 'warning', 2000);
+ });
+
const commitResponse = useCallback((
text: string,
tokens = turnTokensRef.current,
@@ -477,7 +765,7 @@ function RunCodeApp({
// Key handler for picker + esc + abort
const isPickerOrEsc = mode === 'model-picker' || (mode === 'input' && ready && !input) || !ready;
- useInput((ch, key) => {
+ useInput((_ch, key) => {
// Escape during generation → abort current turn (skip if permission dialog open)
if (key.escape && !ready && !permissionRequest) {
onAbort();
@@ -492,8 +780,7 @@ function RunCodeApp({
// In Vim mode: Esc goes to normal mode (handled by VimInput), only quit on Esc in normal mode with empty input
if (key.escape && mode === 'input' && ready && !input) {
if (vimEnabled && currentVimMode === 'insert') return; // Let VimInput handle Esc → normal
- onExit();
- exit();
+ requestExit(false);
return;
}
@@ -559,9 +846,7 @@ function RunCodeApp({
lower === 'exit' || lower === 'quit' || lower === 'q' ||
lower === '/exit' || lower === '/quit';
if (isExit) {
- onAbort();
- onExit();
- exit();
+ requestExit(true);
return;
}
@@ -676,9 +961,7 @@ function RunCodeApp({
// Show user message in scrollback so the conversation is readable
setCommittedResponses(rs => [...rs, {
key: `user-${Date.now()}`,
- // Gold matches the top of the Franklin banner gradient (#FFD700).
- // Brand-consistent, readable on dark terminals, evokes $100-bill identity.
- text: chalk.hex('#FFD700').bold('❯ ') + chalk.hex('#FFD700').bold(trimmed),
+ text: formatUserPromptForDisplay(trimmed),
tokens: { input: 0, output: 0, calls: 0 },
cost: 0,
}]);
@@ -709,7 +992,7 @@ function RunCodeApp({
turnSavingsRef.current = undefined;
turnCtxPctRef.current = undefined;
onSubmit(trimmed);
- }, [ready, currentModel, totalCost, onSubmit, onModelChange, onAbort, onExit, exit, lastPrompt, inputHistory, showStatus]);
+ }, [ready, currentModel, totalCost, onSubmit, onModelChange, requestExit, lastPrompt, inputHistory, showStatus]);
// Mouse support — OFF by default because Node stdin is shared: mouse escape
// sequences leak into Ink's input handler as typed text. Opt in with
@@ -1113,7 +1396,11 @@ function RunCodeApp({
)}
- {renderMarkdown(r.text)}
+ {isUserMsg ? (
+ {r.text}
+ ) : (
+ {renderMarkdown(r.text)}
+ )}
{(r.tokens.input > 0 || r.tokens.output > 0) && (
@@ -1503,8 +1790,20 @@ export function launchInkUI(opts: {
let exiting = false;
let abortCallback: (() => void) | null = null;
const restoreTerminalAutoWrap = disableTerminalAutoWrap();
+ const restoreBracketedPaste = enableBracketedPaste();
+ let cleanedUp = false;
+ let instance: ReturnType | undefined;
+
+ const cleanup = () => {
+ if (cleanedUp) return;
+ cleanedUp = true;
+ mouse.disable();
+ restoreBracketedPaste?.();
+ restoreTerminalAutoWrap?.();
+ instance?.unmount();
+ };
- const instance = render(
+ instance = render(
{
exiting = true;
if (resolveInput) { resolveInput(null); resolveInput = null; }
+ cleanup();
}}
- />
+ />,
+ { exitOnCtrlC: false }
);
return {
@@ -1568,11 +1869,7 @@ export function launchInkUI(opts: {
return new Promise((resolve) => { resolveInput = resolve; });
},
onAbort: (cb: () => void) => { abortCallback = cb; },
- cleanup: () => {
- mouse.disable();
- instance.unmount();
- restoreTerminalAutoWrap?.();
- },
+ cleanup,
requestPermission: (toolName: string, description: string) => {
const ui = (globalThis as Record).__franklin_ui as {
requestPermission: (toolName: string, description: string) => Promise<'yes' | 'no' | 'always'>;