praxis-agent 0.3.0 → 0.4.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.
@@ -3,8 +3,9 @@ import { homedir } from 'node:os';
3
3
  import { resolve } from 'node:path';
4
4
  import { useEffect, useMemo, useRef, useState } from 'react';
5
5
  import { Box, Text, render, useApp, useInput } from 'ink';
6
+ import { AgentRunCancelledError } from '../core/runtime.js';
6
7
  import { redactSensitiveText, sensitiveEnvironmentValues, } from '../platform/sensitive-data.js';
7
- import { CommandPalette, Composer, DiffDashboard, DialogFrame, ExternalEditorWait, HelpMenu, ListDashboard, MentionPicker, PermissionDashboard, SelectionMenu, SessionPicker, StatusDashboard, Transcript, WelcomePanel, useTerminalWidth, } from './tui/claude-style.js';
8
+ import { CommandPalette, BtwPanel, Composer, DiffDashboard, DialogFrame, ExternalEditorWait, HelpMenu, ListDashboard, MentionPicker, PermissionDashboard, SelectionMenu, SessionPicker, StatusDashboard, Transcript, WelcomePanel, useTerminalWidth, } from './tui/claude-style.js';
8
9
  import { loadGitDiff, visiblePatchLines, } from './tui/git-diff.js';
9
10
  import { addTuiPermissionRule, loadTuiPermissionRules, removeTuiPermissionRule, } from './tui/permission-settings.js';
10
11
  import { filterTuiSlashCommands, mergeTuiSlashCommands, slashCommandQuery, } from './tui/slash-commands.js';
@@ -12,7 +13,7 @@ import { createComposerEditor, deleteComposerToEnd, deleteComposerToStart, delet
12
13
  import { applyMentionReference, fileReferenceAtCursor, filterTuiMentionEntries, loadTuiFileEntries, } from './tui/file-picker.js';
13
14
  import { editTuiPrompt, openTuiEditorFile, } from './tui/external-editor.js';
14
15
  import { suspendTuiProcess } from './tui/terminal-suspend.js';
15
- import { readTuiClipboard, writeTuiClipboard, } from './tui/clipboard.js';
16
+ import { readTuiClipboard, writeTuiClipboard, writeTuiOsc52Clipboard, } from './tui/clipboard.js';
16
17
  import { composerImageIds, deleteComposerImageBackward, deleteComposerImageForward, insertComposerImageMarker, moveComposerCursorAcrossImages, } from './tui/composer-images.js';
17
18
  import { defaultTuiKeybindings, ensureTuiKeybindingsFile, hasTuiKeybindingPrefix, loadTuiKeybindings, resolveTuiKeybinding, tuiKeyChord, } from './tui/keybindings.js';
18
19
  import { completeTuiWorkspaceDirectory, resolveTuiWorkspaceDirectory, } from './tui/workspace-directories.js';
@@ -185,7 +186,7 @@ function ordinal(value) {
185
186
  return `${value}th`;
186
187
  }
187
188
  }
188
- export function InteractiveApp({ factory, initialSessions, initialPrompt, initialHistory = [], signal, onCancel, onTurnChange, onCleanup, axScreenReader = false, allowNewSession = true, resume, display = { version: 'dev', cwd: process.cwd() }, terminalWidth, slashCommands = EMPTY_SLASH_COMMANDS, agents = EMPTY_AGENTS, allowDangerouslySkipPermissions = false, additionalDirectories = [], diffLoader, fileLoader, externalEditor = editTuiPrompt, keybindingsConfigRoot, keybindingsFile = ensureTuiKeybindingsFile, keybindingsLoader = loadTuiKeybindings, keybindingsEditor = openTuiEditorFile, suspendProcess = suspendTuiProcess, clipboardReader = readTuiClipboard, clipboardWriter = writeTuiClipboard, exportWriter = writeConversationExport, permissionRuleStore, workspaceDirectoryResolver = resolveTuiWorkspaceDirectory, workspaceDirectoryCompleter = completeTuiWorkspaceDirectory, }) {
189
+ export function InteractiveApp({ factory, initialSessions, initialPrompt, initialHistory = [], signal, onCancel, onTurnChange, onCleanup, axScreenReader = false, allowNewSession = true, resume, display = { version: 'dev', cwd: process.cwd() }, terminalWidth, slashCommands = EMPTY_SLASH_COMMANDS, agents = EMPTY_AGENTS, allowDangerouslySkipPermissions = false, additionalDirectories = [], diffLoader, fileLoader, externalEditor = editTuiPrompt, keybindingsConfigRoot, keybindingsFile = ensureTuiKeybindingsFile, keybindingsLoader = loadTuiKeybindings, keybindingsEditor = openTuiEditorFile, suspendProcess = suspendTuiProcess, clipboardReader = readTuiClipboard, clipboardWriter = writeTuiClipboard, sideQuestionClipboardWriter = writeTuiOsc52Clipboard, exportWriter = writeConversationExport, permissionRuleStore, workspaceDirectoryResolver = resolveTuiWorkspaceDirectory, workspaceDirectoryCompleter = completeTuiWorkspaceDirectory, }) {
189
190
  const { exit, suspendTerminal, waitUntilRenderFlush } = useApp();
190
191
  const width = useTerminalWidth(terminalWidth);
191
192
  const keybindingsRoot = useMemo(() => resolve(keybindingsConfigRoot ??
@@ -246,6 +247,12 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
246
247
  const [editorFooterMessage, setEditorFooterMessage] = useState();
247
248
  const [availableSlashCommands, setAvailableSlashCommands] = useState(slashCommands);
248
249
  const [availableAgents, setAvailableAgents] = useState(agents);
250
+ const [btwHistory, setBtwHistory] = useState([]);
251
+ const btwHistoryRef = useRef([]);
252
+ const btwIdRef = useRef(0);
253
+ const btwControllerRef = useRef(null);
254
+ const [btwCopied, setBtwCopied] = useState(false);
255
+ const btwCopiedTimerRef = useRef(null);
249
256
  const [menu, setMenu] = useState(null);
250
257
  const menuRef = useRef(null);
251
258
  const [busy, setBusy] = useState(false);
@@ -427,8 +434,11 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
427
434
  planApprovalRef.current?.resolve(false);
428
435
  if (exitConfirmationTimerRef.current)
429
436
  clearTimeout(exitConfirmationTimerRef.current);
437
+ if (btwCopiedTimerRef.current)
438
+ clearTimeout(btwCopiedTimerRef.current);
430
439
  scheduledWaitRef.current?.abort();
431
440
  turnControllerRef.current?.abort();
441
+ btwControllerRef.current?.abort();
432
442
  const closing = serviceRef.current?.close?.() ?? Promise.resolve();
433
443
  if (onCleanupRef.current)
434
444
  onCleanupRef.current(closing);
@@ -619,6 +629,11 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
619
629
  menuRef.current = next;
620
630
  setMenu(next);
621
631
  };
632
+ const updateBtwHistory = (updater) => {
633
+ const next = updater(btwHistoryRef.current);
634
+ btwHistoryRef.current = next;
635
+ setBtwHistory(next);
636
+ };
622
637
  useEffect(() => {
623
638
  if (menu?.kind !== 'compact-progress')
624
639
  return;
@@ -1288,6 +1303,126 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
1288
1303
  onTurnChange?.(showing);
1289
1304
  void showing.finally(() => onTurnChange?.(null));
1290
1305
  };
1306
+ const showBtwUsage = () => {
1307
+ appendPromptHistory('/btw');
1308
+ const showing = (async () => {
1309
+ try {
1310
+ const activeSessionId = await (await service()).recordBtwUsage?.(sessionId ?? undefined, runtimePreferencesRef.current.permissionMode);
1311
+ if (activeSessionId)
1312
+ setSessionId(activeSessionId);
1313
+ append({ kind: 'user', text: '/btw' });
1314
+ append({ kind: 'local-result', text: 'Usage: /btw <your question>' });
1315
+ }
1316
+ catch (error) {
1317
+ warn(error);
1318
+ }
1319
+ })();
1320
+ onTurnChange?.(showing);
1321
+ void showing.finally(() => onTurnChange?.(null));
1322
+ };
1323
+ const askSideQuestion = (question) => {
1324
+ const id = btwIdRef.current + 1;
1325
+ btwIdRef.current = id;
1326
+ appendPromptHistory(`/btw ${question}`);
1327
+ const entry = {
1328
+ id,
1329
+ question,
1330
+ answer: '',
1331
+ status: 'answering',
1332
+ };
1333
+ updateBtwHistory((entries) => [...entries, entry]);
1334
+ updateMenu({
1335
+ kind: 'btw',
1336
+ selectedIndex: btwHistoryRef.current.length - 1,
1337
+ scrollOffset: 0,
1338
+ });
1339
+ setBtwCopied(false);
1340
+ const controller = new AbortController();
1341
+ btwControllerRef.current?.abort();
1342
+ btwControllerRef.current = controller;
1343
+ const sideSignal = signal
1344
+ ? AbortSignal.any([signal, controller.signal])
1345
+ : controller.signal;
1346
+ const answering = (async () => {
1347
+ try {
1348
+ const commands = await service();
1349
+ if (!commands.answerSideQuestion) {
1350
+ throw new Error('Side questions are unavailable.');
1351
+ }
1352
+ const result = await commands.answerSideQuestion(sessionId ?? undefined, question, sideSignal, (delta) => updateBtwHistory((entries) => entries.map((item) => item.id === id
1353
+ ? { ...item, answer: item.answer + delta }
1354
+ : item)), runtimePreferencesRef.current.permissionMode);
1355
+ if (result.sessionId)
1356
+ setSessionId(result.sessionId);
1357
+ updateBtwHistory((entries) => entries.map((item) => item.id === id ? { ...item, status: 'complete' } : item));
1358
+ if (sideSignal.aborted)
1359
+ throw new AgentRunCancelledError();
1360
+ setUsage((current) => ({
1361
+ inputTokens: (current?.inputTokens ?? 0) + result.usage.inputTokens,
1362
+ outputTokens: (current?.outputTokens ?? 0) + result.usage.outputTokens,
1363
+ cacheReadInputTokens: (current?.cacheReadInputTokens ?? 0) +
1364
+ (result.usage.cacheReadInputTokens ?? 0),
1365
+ cacheCreationInputTokens: (current?.cacheCreationInputTokens ?? 0) +
1366
+ (result.usage.cacheCreationInputTokens ?? 0),
1367
+ }));
1368
+ if (result.costUsd !== undefined) {
1369
+ const sideCostUsd = result.costUsd;
1370
+ setCostUsd((current) => (current ?? 0) + sideCostUsd);
1371
+ }
1372
+ }
1373
+ catch (error) {
1374
+ updateBtwHistory((entries) => entries.map((item) => item.id === id
1375
+ ? {
1376
+ ...item,
1377
+ status: 'error',
1378
+ error: sideSignal.aborted
1379
+ ? 'Cancelled'
1380
+ : error instanceof Error
1381
+ ? error.message
1382
+ : String(error),
1383
+ }
1384
+ : item));
1385
+ }
1386
+ finally {
1387
+ if (btwControllerRef.current === controller) {
1388
+ btwControllerRef.current = null;
1389
+ }
1390
+ }
1391
+ })();
1392
+ onTurnChange?.(answering);
1393
+ void answering.finally(() => onTurnChange?.(null));
1394
+ };
1395
+ const forkSideQuestion = (entry) => {
1396
+ updateBtwHistory((entries) => entries.map((item) => item.id === entry.id ? { ...item, status: 'forking' } : item));
1397
+ const forking = (async () => {
1398
+ try {
1399
+ if (!sessionId) {
1400
+ throw new Error('Start a conversation before forking a side question.');
1401
+ }
1402
+ const commands = await service();
1403
+ if (!commands.forkSideQuestion) {
1404
+ throw new Error('Side-question forking is unavailable.');
1405
+ }
1406
+ const result = await commands.forkSideQuestion(sessionId, entry.question);
1407
+ updateMenu(null);
1408
+ append({
1409
+ kind: 'local-result',
1410
+ text: `⑂ forked ${result.name} (${result.agentId.slice(-4)})`,
1411
+ });
1412
+ }
1413
+ catch (error) {
1414
+ updateBtwHistory((entries) => entries.map((item) => item.id === entry.id
1415
+ ? {
1416
+ ...item,
1417
+ status: 'error',
1418
+ error: error instanceof Error ? error.message : String(error),
1419
+ }
1420
+ : item));
1421
+ }
1422
+ })();
1423
+ onTurnChange?.(forking);
1424
+ void forking.finally(() => onTurnChange?.(null));
1425
+ };
1291
1426
  const renameSession = (requestedName) => {
1292
1427
  const renaming = (async () => {
1293
1428
  if (!sessionId) {
@@ -2019,6 +2154,69 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
2019
2154
  }
2020
2155
  const activeMenu = menuRef.current;
2021
2156
  if (activeMenu) {
2157
+ if (activeMenu.kind === 'btw') {
2158
+ const selected = btwHistoryRef.current[activeMenu.selectedIndex];
2159
+ if (key.escape || value === '\u001B') {
2160
+ btwControllerRef.current?.abort();
2161
+ btwControllerRef.current = null;
2162
+ updateMenu(null);
2163
+ return;
2164
+ }
2165
+ if (key.leftArrow || key.rightArrow) {
2166
+ updateMenu({
2167
+ ...activeMenu,
2168
+ selectedIndex: Math.max(0, Math.min(btwHistoryRef.current.length - 1, activeMenu.selectedIndex + (key.leftArrow ? -1 : 1))),
2169
+ scrollOffset: 0,
2170
+ });
2171
+ setBtwCopied(false);
2172
+ return;
2173
+ }
2174
+ if (key.upArrow || key.downArrow) {
2175
+ const maxOffset = Math.max(0, (selected?.answer.split('\n').length ?? 0) - 16);
2176
+ updateMenu({
2177
+ ...activeMenu,
2178
+ scrollOffset: Math.max(0, Math.min(maxOffset, activeMenu.scrollOffset + (key.upArrow ? -1 : 1))),
2179
+ });
2180
+ return;
2181
+ }
2182
+ if (lower === 'x' && btwHistoryRef.current.length > 1 && selected) {
2183
+ updateBtwHistory(() => [selected]);
2184
+ updateMenu({ kind: 'btw', selectedIndex: 0, scrollOffset: 0 });
2185
+ setBtwCopied(false);
2186
+ return;
2187
+ }
2188
+ if (lower === 'c' && selected?.status === 'complete') {
2189
+ const copying = sideQuestionClipboardWriter(selected.answer).then(() => {
2190
+ if (!componentMountedRef.current)
2191
+ return;
2192
+ setBtwCopied(true);
2193
+ if (btwCopiedTimerRef.current) {
2194
+ clearTimeout(btwCopiedTimerRef.current);
2195
+ }
2196
+ btwCopiedTimerRef.current = setTimeout(() => {
2197
+ if (componentMountedRef.current)
2198
+ setBtwCopied(false);
2199
+ btwCopiedTimerRef.current = null;
2200
+ }, 1500);
2201
+ btwCopiedTimerRef.current.unref?.();
2202
+ }, (error) => updateBtwHistory((entries) => entries.map((item) => item.id === selected.id
2203
+ ? {
2204
+ ...item,
2205
+ status: 'error',
2206
+ error: error instanceof Error
2207
+ ? error.message
2208
+ : String(error),
2209
+ }
2210
+ : item)));
2211
+ onTurnChange?.(copying);
2212
+ void copying.finally(() => onTurnChange?.(null));
2213
+ return;
2214
+ }
2215
+ if (lower === 'f' && selected?.status === 'complete') {
2216
+ forkSideQuestion(selected);
2217
+ }
2218
+ return;
2219
+ }
2022
2220
  if (activeMenu.kind === 'help') {
2023
2221
  if (key.escape) {
2024
2222
  updateMenu(null);
@@ -2790,6 +2988,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
2790
2988
  const copyCommand = /^\/copy(?:\s+(\d+))?$/u.exec(prompt);
2791
2989
  const renameCommand = /^\/rename(?:\s+(.+))?$/u.exec(prompt);
2792
2990
  const cdCommand = /^\/cd(?:\s+(.+))?$/u.exec(prompt);
2991
+ const btwCommand = /^\/btw(?:\s+([\s\S]+))?$/u.exec(prompt);
2793
2992
  if (prompt === '/exit') {
2794
2993
  exit();
2795
2994
  }
@@ -2830,6 +3029,13 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
2830
3029
  else if (prompt === '/add-dir') {
2831
3030
  openWorkspaceDirectoryInput();
2832
3031
  }
3032
+ else if (btwCommand) {
3033
+ const sideQuestion = btwCommand[1]?.trim();
3034
+ if (sideQuestion)
3035
+ askSideQuestion(sideQuestion);
3036
+ else
3037
+ showBtwUsage();
3038
+ }
2833
3039
  else if (cdCommand) {
2834
3040
  append({ kind: 'user', text: prompt });
2835
3041
  const requestedCwd = cdCommand[1]?.trim();
@@ -3017,7 +3223,7 @@ export function InteractiveApp({ factory, initialSessions, initialPrompt, initia
3017
3223
  ? `Retry interrupted ${permission.call.name}?`
3018
3224
  : `Allow ${permission.call.name}?`, screenReader: axScreenReader, children: [_jsx(Text, { bold: true, children: describeTool(permission.call, sensitiveValues) }), _jsx(Text, { children: "\u276F 1. Yes" }), _jsx(Text, { children: " 2. No" }), _jsx(Text, { dimColor: true, children: "Enter/Esc declines \u00B7 y/n quick response" })] })) : planApproval ? (_jsxs(DialogFrame, { title: "Approve this plan and begin implementation?", screenReader: axScreenReader, children: [_jsx(Text, { dimColor: true, children: planApproval.request.planPath }), planApproval.request.plan ? (_jsx(Box, { marginY: 1, children: _jsx(Text, { children: planApproval.request.plan }) })) : null, _jsx(Text, { children: "\u276F 1. Yes, implement the plan" }), _jsx(Text, { children: " 2. No, keep planning" })] })) : question ? (_jsxs(DialogFrame, { title: `${question.questions[question.index]?.header}: ${question.questions[question.index]?.question}`, screenReader: axScreenReader, children: [question.questions[question.index]?.options.map((option, index) => (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Text, { children: [index === 0 ? '❯ ' : ' ', index + 1, ". ", option.label, " \u2014 ", option.description] }), option.preview ? (_jsx(Text, { dimColor: true, children: option.preview })) : null] }, `${index}-${option.label}`))), _jsxs(Text, { children: ["\u203A ", input] }), _jsx(Text, { dimColor: true, children: question.questions[question.index]?.multiSelect
3019
3225
  ? 'Enter comma-separated option numbers or custom text · Esc cancels'
3020
- : 'Enter one option number or custom text · Esc cancels' })] })) : elicitation ? (_jsxs(DialogFrame, { title: `MCP elicitation (${elicitation.request.serverName})`, screenReader: axScreenReader, children: [_jsx(Text, { children: elicitation.request.message }), elicitation.request.url ? (_jsx(Text, { children: elicitation.request.url })) : null, elicitation.request.requestedSchema ? (_jsx(Text, { dimColor: true, children: JSON.stringify(elicitation.request.requestedSchema) })) : null, _jsxs(Text, { children: ["\u203A ", input] }), _jsx(Text, { dimColor: true, children: "Enter JSON object to accept \u00B7 Esc to cancel" })] })) : menu?.kind === 'model-input' ? (_jsxs(DialogFrame, { title: "Enter model ID", screenReader: axScreenReader, children: [_jsx(Text, { dimColor: true, children: "Enter a model ID supported by the configured provider." }), _jsxs(Text, { children: ["\u203A ", input] }), _jsx(Text, { dimColor: true, children: "Enter confirms \u00B7 Esc cancels" })] })) : menu?.kind === 'export-filename' ? (_jsxs(DialogFrame, { title: "Enter filename:", screenReader: axScreenReader, children: [_jsxs(Text, { children: ["> ", input] }), _jsx(Text, { dimColor: true, children: "Enter to save \u00B7 Esc to go back" })] })) : menu?.kind === 'compact-progress' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "\u273B Compacting conversation\u2026" }), _jsxs(Text, { children: ['▰'.repeat(Math.floor(compactProgress / 2)), '▱'.repeat(50 - Math.floor(compactProgress / 2)), ' ', compactProgress, "%"] })] })) : menu?.kind === 'rewind' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " Rewind" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [' ', "Restore the code and/or conversation to the point before\u2026"] }), _jsx(Text, { children: " " }), (() => {
3226
+ : 'Enter one option number or custom text · Esc cancels' })] })) : elicitation ? (_jsxs(DialogFrame, { title: `MCP elicitation (${elicitation.request.serverName})`, screenReader: axScreenReader, children: [_jsx(Text, { children: elicitation.request.message }), elicitation.request.url ? (_jsx(Text, { children: elicitation.request.url })) : null, elicitation.request.requestedSchema ? (_jsx(Text, { dimColor: true, children: JSON.stringify(elicitation.request.requestedSchema) })) : null, _jsxs(Text, { children: ["\u203A ", input] }), _jsx(Text, { dimColor: true, children: "Enter JSON object to accept \u00B7 Esc to cancel" })] })) : menu?.kind === 'model-input' ? (_jsxs(DialogFrame, { title: "Enter model ID", screenReader: axScreenReader, children: [_jsx(Text, { dimColor: true, children: "Enter a model ID supported by the configured provider." }), _jsxs(Text, { children: ["\u203A ", input] }), _jsx(Text, { dimColor: true, children: "Enter confirms \u00B7 Esc cancels" })] })) : menu?.kind === 'export-filename' ? (_jsxs(DialogFrame, { title: "Enter filename:", screenReader: axScreenReader, children: [_jsxs(Text, { children: ["> ", input] }), _jsx(Text, { dimColor: true, children: "Enter to save \u00B7 Esc to go back" })] })) : menu?.kind === 'compact-progress' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "\u273B Compacting conversation\u2026" }), _jsxs(Text, { children: ['▰'.repeat(Math.floor(compactProgress / 2)), '▱'.repeat(50 - Math.floor(compactProgress / 2)), ' ', compactProgress, "%"] })] })) : menu?.kind === 'btw' ? (_jsx(BtwPanel, { entries: btwHistory, selectedIndex: menu.selectedIndex, scrollOffset: menu.scrollOffset, copied: btwCopied, width: width, screenReader: axScreenReader })) : menu?.kind === 'rewind' ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { bold: true, children: " Rewind" }), _jsx(Text, { children: " " }), _jsxs(Text, { children: [' ', "Restore the code and/or conversation to the point before\u2026"] }), _jsx(Text, { children: " " }), (() => {
3021
3227
  const window = rewindPointWindow(menu.points, menu.selectedIndex);
3022
3228
  return (_jsxs(_Fragment, { children: [window.start > 0 ? (_jsxs(Text, { dimColor: true, children: [" \u2191 ", window.start, " more above"] })) : null, menu.points
3023
3229
  .slice(window.start, window.end)