@thegitai/cli 1.0.0-preview.5 → 1.0.0-preview.51

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.
Files changed (76) hide show
  1. package/README.md +93 -4
  2. package/dist/bin/ai.js +142 -112
  3. package/dist/bin/browser-host.js +265 -0
  4. package/dist/src/agent-mode.js +32 -7
  5. package/dist/src/api/auth.js +10 -4
  6. package/dist/src/api/browser-login.js +149 -37
  7. package/dist/src/api/chat.js +680 -166
  8. package/dist/src/api/contracts.js +55 -1
  9. package/dist/src/api/default-host.js +1 -0
  10. package/dist/src/api/http.js +151 -7
  11. package/dist/src/api/models.js +29 -10
  12. package/dist/src/api/startup-handshake.js +36 -0
  13. package/dist/src/api/turn-recovery.js +189 -0
  14. package/dist/src/background-jobs.js +4 -2
  15. package/dist/src/browser/bridge.js +262 -0
  16. package/dist/src/browser/framing.js +42 -0
  17. package/dist/src/browser/native-host.js +219 -0
  18. package/dist/src/browser/protocol.js +46 -0
  19. package/dist/src/browser/session-bridge.js +130 -0
  20. package/dist/src/cli-args.js +19 -5
  21. package/dist/src/client-environment.js +2 -0
  22. package/dist/src/core/clipboard.js +7 -13
  23. package/dist/src/core/image-limits.js +56 -0
  24. package/dist/src/core/image-path-extractor.js +70 -3
  25. package/dist/src/core/session-image-store.js +246 -0
  26. package/dist/src/edit-journal.js +33 -2
  27. package/dist/src/executor.js +25 -3
  28. package/dist/src/help-text.js +112 -21
  29. package/dist/src/patcher.js +43 -4
  30. package/dist/src/permissions.js +288 -0
  31. package/dist/src/preferences.js +39 -0
  32. package/dist/src/quota-notice.js +35 -0
  33. package/dist/src/session-safety.js +0 -12
  34. package/dist/src/session-store.js +121 -20
  35. package/dist/src/session.js +19 -4
  36. package/dist/src/signin.js +58 -0
  37. package/dist/src/tool-executor.js +13 -46
  38. package/dist/src/tools/browser.js +641 -0
  39. package/dist/src/tools/delete-file.js +20 -4
  40. package/dist/src/tools/index.js +32 -10
  41. package/dist/src/tools/patch-file.js +12 -26
  42. package/dist/src/tools/read-file.js +5 -0
  43. package/dist/src/tools/read-image-file.js +85 -0
  44. package/dist/src/tools/replace-document-text.js +28 -18
  45. package/dist/src/tools/restore-checkpoint.js +0 -1
  46. package/dist/src/tools/run-command.js +14 -71
  47. package/dist/src/tools/run-node-script.js +12 -81
  48. package/dist/src/tools/save-generated-image.js +120 -0
  49. package/dist/src/tools/str-replace.js +12 -26
  50. package/dist/src/tools/undo-edit.js +1 -6
  51. package/dist/src/tools/write-file.js +67 -11
  52. package/dist/src/ui/prompt-history-store.js +1 -1
  53. package/dist/src/ui/repl.js +949 -279
  54. package/dist/src/ui/slash-commands.js +121 -0
  55. package/dist/src/ui/tui/attention.js +25 -0
  56. package/dist/src/ui/tui/bridge.js +38 -5
  57. package/dist/src/ui/tui/build-frame.js +686 -223
  58. package/dist/src/ui/tui/composer-layout.js +76 -0
  59. package/dist/src/ui/tui/desktop-notification.js +118 -0
  60. package/dist/src/ui/tui/markdown-render.js +11 -2
  61. package/dist/src/ui/tui/shell-input.js +256 -73
  62. package/dist/src/ui/tui/shortcuts.js +61 -0
  63. package/dist/src/ui/tui/terminal-theme.js +28 -0
  64. package/dist/src/ui/tui/user-input.js +593 -0
  65. package/dist/src/update-check.js +205 -0
  66. package/dist/src/usage.js +5 -2
  67. package/dist/src/utils.js +13 -0
  68. package/dist/src/version.js +52 -2
  69. package/package.json +22 -9
  70. package/dist/src/project-index.js +0 -233
  71. package/dist/src/tools/code-intel.js +0 -472
  72. package/dist/src/tools/find-symbol.js +0 -70
  73. package/dist/src/tools/hover-symbol.js +0 -95
  74. package/dist/src/tools/list-symbols.js +0 -55
  75. package/dist/src/tools/search-code.js +0 -37
  76. package/dist/src/tools/signature-help.js +0 -118
@@ -1,20 +1,33 @@
1
+ import { basename } from 'node:path';
1
2
  import { createRatatuiBridge } from './tui/bridge.js';
2
- import { buildTuiFrame, formatJobElapsed, formatTodoProgress, renderTranscriptEntryLines, } from './tui/build-frame.js';
3
+ import { bucketActionLabel, } from '../permissions.js';
4
+ import { AGENT_MODE_LOCKED_STATUS, approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, userInputViewportForFrame, } from './tui/build-frame.js';
5
+ import { createAttentionController, INITIAL_TERMINAL_FOCUS, } from './tui/attention.js';
6
+ import { backendFor, createNotificationQueue, } from './tui/desktop-notification.js';
7
+ import { readPreferences, writePreferences } from '../preferences.js';
3
8
  import { createTerminalTitleController } from './tui/terminal-title.js';
4
9
  import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
5
- export { getSlashCommandSuggestions } from './tui/build-frame.js';
10
+ import { getInputCommandToken, getSlashCommandSuggestions, SLASH_COMMANDS, slashCommandMatchesToken, } from './slash-commands.js';
11
+ export { getInputCommandToken, getSlashCommandSuggestions, SLASH_COMMANDS, slashCommandMatchesToken, };
12
+ import { composerInputWidth, frameContentWidth } from './tui/composer-layout.js';
6
13
  import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
7
14
  import { chat, models } from '../api/index.js';
8
- import { isTurnCancelledError } from '../api/chat.js';
15
+ import { isTurnCancelledError, isTurnOutcomeKnown } from '../api/chat.js';
16
+ import { formatTurnRecoveryStatus } from '../api/turn-recovery.js';
9
17
  import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
10
18
  import { clearTodos, listTodos, setTodoSession } from '../todo-list.js';
11
19
  import { setScratchSession } from '../scratch-dir.js';
20
+ import { setImageStoreSession } from '../core/session-image-store.js';
12
21
  import { cancelActiveCommand } from '../executor.js';
13
22
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
23
+ import { clearCliAuthConfig, logoutFromServer, } from '../api/auth.js';
24
+ import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
25
+ import { buildQuotaNotice } from '../quota-notice.js';
26
+ import { createUserInputPromptState, formatUserInputTranscript, } from './tui/user-input.js';
14
27
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
15
28
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
16
- import { clearConversation, } from '../session.js';
17
- import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
29
+ import { startNewConversation, } from '../session.js';
30
+ import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, sessionHasUserMessage, } from '../session-store.js';
18
31
  import { truncate } from '../utils.js';
19
32
  import { writeClipboardText } from '../core/clipboard.js';
20
33
  import { openUrl } from '../core/open-url.js';
@@ -70,58 +83,46 @@ const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
70
83
  const WORKING_TOOL_PREVIEW_ITEMS = 4;
71
84
  const THINKING_NOTE_PREVIEW_ROWS = 3;
72
85
  const WORKING_TOOL_PREVIEW_ROWS = 3;
73
- const AGENT_MODE_LABEL_WIDTH = 16;
74
- const APPROVAL_OPTIONS = [
75
- {
76
- value: 'y',
77
- label: 'Approve once',
78
- description: 'Run this action only this time',
79
- },
80
- {
81
- value: 'a',
82
- label: 'Approve all remaining actions',
83
- description: 'Turn on auto-approve for the rest of the session',
84
- },
85
- {
86
- value: 'n',
87
- label: 'Deny',
88
- description: 'Reject this action',
89
- },
90
- ];
91
- export const SLASH_COMMANDS = [
92
- {
93
- command: '/help',
94
- description: 'Show available chat commands',
95
- },
96
- {
97
- command: '/about',
98
- description: 'Show version and platform info',
99
- },
100
- {
101
- command: '/usage',
102
- description: 'Show account usage percentage and reset times',
103
- },
104
- {
105
- command: '/model',
106
- description: 'Switch the active model',
107
- },
108
- {
109
- command: '/resume',
110
- description: 'Open the session picker',
111
- },
112
- {
113
- command: '/jobs',
114
- description: 'Background jobs: pick to view output or kill',
115
- },
116
- {
117
- command: '/clear',
118
- description: 'Clear conversation history',
119
- },
120
- {
121
- command: '/exit',
122
- description: 'Quit the current session',
123
- },
124
- ];
86
+ export const REPORT_ISSUE_URL = 'https://thegit.ai/report';
87
+ export function buildReportTranscriptEntries(openedUrl) {
88
+ return openedUrl
89
+ ? [
90
+ {
91
+ body: `Opening the private bug report form in your browser. If nothing opened, visit:\n\n${REPORT_ISSUE_URL}\n`,
92
+ kind: 'system',
93
+ title: 'Report',
94
+ },
95
+ ]
96
+ : [
97
+ {
98
+ body: `Could not open a browser. Report the bug manually at:\n\n${REPORT_ISSUE_URL}\n`,
99
+ kind: 'error',
100
+ title: 'Report',
101
+ },
102
+ ];
103
+ }
104
+ export function buildTurnFailureHintEntries(cancelled) {
105
+ if (cancelled)
106
+ return [];
107
+ return [
108
+ {
109
+ body: 'Something went wrong? Use /report to submit the issue.',
110
+ kind: 'error',
111
+ title: 'Report',
112
+ },
113
+ ];
114
+ }
115
+ export function composerAttachmentsAfterCancel(queuedAttachments, inFlightAttachments) {
116
+ const seen = new Set();
117
+ const kept = [];
118
+ for (const attachment of [...queuedAttachments, ...inFlightAttachments]) {
119
+ if (seen.has(attachment.index))
120
+ continue;
121
+ seen.add(attachment.index);
122
+ kept.push(attachment);
123
+ }
124
+ return kept;
125
+ }
125
126
  function getShellWidth(columns) {
126
127
  const safeColumns = Math.max(columns, 20);
127
128
  const targetWidth = Math.floor(safeColumns * TUI_WIDTH_RATIO);
@@ -490,11 +491,43 @@ function buildWriteFileDiff(content) {
490
491
  return '';
491
492
  return `@@ -0,0 +1,${lines.length} @@\n${lines.map((line) => `+${line}`).join('\n')}`;
492
493
  }
494
+ const MAX_DELETE_PREVIEW_LINES = 400;
493
495
  function buildDeleteFileDiff(content) {
494
496
  const lines = splitDiffLines(String(content ?? ''));
495
- if (lines.length === 0)
497
+ if (lines.length === 0) {
498
+ return { removed: 0, text: '' };
499
+ }
500
+ const shown = lines.slice(0, MAX_DELETE_PREVIEW_LINES);
501
+ const rows = [
502
+ `@@ -1,${lines.length} +0,0 @@`,
503
+ ...shown.map((line) => `-${line}`),
504
+ ];
505
+ const omitted = lines.length - shown.length;
506
+ if (omitted > 0) {
507
+ rows.push(`@@ ${omitted} more removed line(s) not shown @@`);
508
+ }
509
+ return { removed: lines.length, text: rows.join('\n') };
510
+ }
511
+ function deletedFileSummary(result) {
512
+ const bytes = typeof result?.bytes === 'number' ? result.bytes : null;
513
+ if (bytes === null) {
496
514
  return '';
497
- return `@@ -1,${lines.length} +0,0 @@\n${lines.map((line) => `-${line}`).join('\n')}`;
515
+ }
516
+ const reason = result?.contentOmitted === 'binary' ? ', binary' : '';
517
+ return ` (${formatFileSize(bytes)}${reason})`;
518
+ }
519
+ function formatFileSize(bytes) {
520
+ if (bytes < 1024) {
521
+ return `${bytes} B`;
522
+ }
523
+ const units = ['KiB', 'MiB', 'GiB', 'TiB'];
524
+ let value = bytes / 1024;
525
+ let unit = 0;
526
+ while (value >= 1024 && unit < units.length - 1) {
527
+ value /= 1024;
528
+ unit += 1;
529
+ }
530
+ return `${value >= 10 ? Math.round(value) : value.toFixed(1)} ${units[unit]}`;
498
531
  }
499
532
  function isFileChangeTool(name) {
500
533
  return (name === 'patch_file' ||
@@ -510,13 +543,7 @@ function getToolFilePath(call, result) {
510
543
  '').trim();
511
544
  }
512
545
  function formatToolResultState(result) {
513
- if (result?.skipped)
514
- return 'Skipped';
515
- if (result?.blocked)
516
- return 'Blocked';
517
- if (result?.ok === true)
518
- return 'OK';
519
- return 'Failed';
546
+ return result?.skipped ? 'Skipped' : 'OK';
520
547
  }
521
548
  function formatRunCommandResultState(result) {
522
549
  if (result?.timedOut)
@@ -554,12 +581,15 @@ function buildFileChangeEntry(event) {
554
581
  : call.name === 'undo_edit'
555
582
  ? 'Undo'
556
583
  : 'Edit';
557
- return {
558
- body: error || `${call.name} ${filePath}`,
559
- filePath,
560
- kind: skipped ? 'system' : 'error',
561
- title: skipped ? `${verb} skipped: ${filePath}` : `${verb} failed: ${filePath}`,
562
- };
584
+ if (skipped) {
585
+ return {
586
+ body: error || `${call.name} ${filePath}`,
587
+ filePath,
588
+ kind: 'system',
589
+ title: `${verb} skipped: ${filePath}`,
590
+ };
591
+ }
592
+ return { body: '', filePath, kind: 'system', title: `${verb}: ${filePath}` };
563
593
  }
564
594
  if (result?.scratch === true) {
565
595
  const scratchName = truncate(filePath.split(/[\\/]/).filter(Boolean).at(-1) ?? filePath, 72);
@@ -620,9 +650,11 @@ function buildFileChangeEntry(event) {
620
650
  };
621
651
  }
622
652
  const content = typeof result?.content === 'string' ? result.content : '';
623
- const diffText = buildDeleteFileDiff(content);
624
- const preview = diffText ? parseDiffPreview(diffText) : undefined;
625
- const summary = preview ? ` (+0 -${preview.removed})` : '';
653
+ const diff = buildDeleteFileDiff(content);
654
+ const preview = diff.text ? parseDiffPreview(diff.text) : undefined;
655
+ const summary = preview
656
+ ? ` (+0 -${diff.removed})`
657
+ : deletedFileSummary(result);
626
658
  return {
627
659
  body: '',
628
660
  diffPreview: preview,
@@ -672,24 +704,27 @@ function buildFileChangeEntry(event) {
672
704
  title: `Edited ${filePath}${summary}`,
673
705
  };
674
706
  }
675
- function buildWorkingToolEntry(event) {
707
+ export function buildWorkingToolEntry(event) {
676
708
  const { call, result } = event;
677
- const error = typeof result?.error === 'string' && result.error.trim()
709
+ const failed = result?.ok !== true && result?.skipped !== true;
710
+ const kind = result?.ok === true ? 'tool' : 'system';
711
+ const state = (text) => (failed ? '' : text);
712
+ const error = !failed && typeof result?.error === 'string' && result.error.trim()
678
713
  ? `\n${truncate(result.error.trim(), 180)}`
679
714
  : '';
680
715
  if (call.name === 'run_command') {
681
716
  const command = String(call.args?.command ?? result?.command ?? '').trim();
682
717
  if (result?.backgrounded === true && result?.jobId) {
683
718
  return {
684
- body: `$ ${truncate(command, 180)}\nBackground job ${String(result.jobId)} started.${error}`,
685
- kind: result?.ok === true ? 'tool' : 'error',
719
+ body: `$ ${truncate(command, 180)}${state(`\nBackground job ${String(result.jobId)} started.`)}${error}`,
720
+ kind,
686
721
  preformatted: true,
687
722
  title: 'Shell',
688
723
  };
689
724
  }
690
725
  return {
691
- body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
692
- kind: result?.ok === true ? 'tool' : 'error',
726
+ body: `$ ${truncate(command, 180)}${state(`\n${formatRunCommandResultState(result)}`)}${error}`,
727
+ kind,
693
728
  title: 'Shell',
694
729
  };
695
730
  }
@@ -701,15 +736,15 @@ function buildWorkingToolEntry(event) {
701
736
  ? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
702
737
  : formatToolResultState(result);
703
738
  return {
704
- body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
705
- kind: result?.ok === true ? 'tool' : 'error',
739
+ body: `${jobId || '(unknown job)'}${state(` · ${stateText}`)}${error}`,
740
+ kind,
706
741
  title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
707
742
  };
708
743
  }
709
744
  if (call.name === 'run_node_script') {
710
745
  return {
711
- body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
712
- kind: result?.ok === true ? 'tool' : 'error',
746
+ body: `node --input-type=module <script via stdin>${state(`\n${formatRunCommandResultState(result)}`)}${error}`,
747
+ kind,
713
748
  title: 'Node',
714
749
  };
715
750
  }
@@ -718,8 +753,8 @@ function buildWorkingToolEntry(event) {
718
753
  }
719
754
  const args = formatToolArgs(call, result);
720
755
  return {
721
- body: `${call.name}${args ? ` ${args}` : ''} -> ${formatToolResultState(result)}${error}`,
722
- kind: result?.ok === true ? 'tool' : 'error',
756
+ body: `${call.name}${args ? ` ${args}` : ''}${state(` -> ${formatToolResultState(result)}`)}${error}`,
757
+ kind,
723
758
  title: 'Tool',
724
759
  };
725
760
  }
@@ -785,10 +820,18 @@ function displayUserTextFromHistoryEntry(entry) {
785
820
  export function buildTranscriptFromSessionHistory(history) {
786
821
  const entries = [];
787
822
  const pendingCalls = new Map();
823
+ const pendingUserInputRequests = new Map();
788
824
  for (const entry of history) {
789
825
  if (!entry?.parts?.length)
790
826
  continue;
791
827
  for (const part of entry.parts) {
828
+ const userInputRequest = part?.userInputRequest;
829
+ if (userInputRequest && typeof userInputRequest === 'object') {
830
+ const requestId = String(userInputRequest.requestId ?? '').trim();
831
+ if (requestId && Array.isArray(userInputRequest.questions)) {
832
+ pendingUserInputRequests.set(requestId, userInputRequest.questions);
833
+ }
834
+ }
792
835
  const call = part?.functionCall;
793
836
  if (!call || typeof call !== 'object')
794
837
  continue;
@@ -798,6 +841,21 @@ export function buildTranscriptFromSessionHistory(history) {
798
841
  pendingCalls.set(callId, call);
799
842
  }
800
843
  for (const part of entry.parts) {
844
+ const userInputResult = part?.userInputResult;
845
+ if (userInputResult && typeof userInputResult === 'object') {
846
+ const requestId = String(userInputResult.requestId ?? '').trim();
847
+ const questions = pendingUserInputRequests.get(requestId);
848
+ const result = userInputResult.result;
849
+ if (questions &&
850
+ (result?.status === 'submitted' || result?.status === 'cancelled')) {
851
+ pendingUserInputRequests.delete(requestId);
852
+ entries.push({
853
+ body: formatUserInputTranscript(questions, result),
854
+ kind: result.status === 'submitted' ? 'user' : 'system',
855
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
856
+ });
857
+ }
858
+ }
801
859
  const functionResponse = part?.functionResponse;
802
860
  if (!functionResponse || typeof functionResponse !== 'object')
803
861
  continue;
@@ -814,10 +872,10 @@ export function buildTranscriptFromSessionHistory(history) {
814
872
  }
815
873
  if (!call || !isFileChangeTool(call.name))
816
874
  continue;
817
- entries.push(buildFileChangeEntry({
818
- call,
819
- result: functionResponse.response ?? {},
820
- }));
875
+ const result = (functionResponse.response ?? {});
876
+ if (result.ok !== true && result.skipped !== true)
877
+ continue;
878
+ entries.push(buildFileChangeEntry({ call, result }));
821
879
  }
822
880
  if (entry.role === 'user' && entry.kind === 'turnStart') {
823
881
  const text = displayUserTextFromHistoryEntry(entry);
@@ -826,6 +884,13 @@ export function buildTranscriptFromSessionHistory(history) {
826
884
  }
827
885
  continue;
828
886
  }
887
+ if (entry.role === 'user' && entry.kind === 'userInterjection') {
888
+ const text = String(entry.userInput ?? '').trim();
889
+ if (text) {
890
+ entries.push({ body: text, kind: 'user', title: 'You · sent mid-turn' });
891
+ }
892
+ continue;
893
+ }
829
894
  const text = textFromHistoryEntry(entry);
830
895
  if ((entry.role === 'model' || entry.role === 'assistant') && text) {
831
896
  if (isTurnFailureMarker(text)) {
@@ -854,6 +919,23 @@ function createSessionTranscript(session) {
854
919
  const transcript = buildTranscriptFromSessionHistory(session.history);
855
920
  return transcript.length ? transcript : createInitialTranscript();
856
921
  }
922
+ export function isJobsPickerCommand(input) {
923
+ return String(input ?? '').trim() === '/jobs';
924
+ }
925
+ export function statusBehindJobsPicker(state) {
926
+ return state.jobsPickerOpen
927
+ ? (state.jobsPickerReturnStatus ?? 'Ready')
928
+ : state.status;
929
+ }
930
+ export function closedJobsPickerFields(state) {
931
+ if (!state.jobsPickerOpen)
932
+ return {};
933
+ return {
934
+ jobsPickerExpandedId: null,
935
+ jobsPickerOpen: false,
936
+ jobsPickerReturnStatus: null,
937
+ };
938
+ }
857
939
  function createShellStore(initialState) {
858
940
  let state = initialState;
859
941
  let nextEntryId = 1;
@@ -961,11 +1043,15 @@ function createInitialShellState(session, serverModels, debugUi) {
961
1043
  activeTurnInput: '',
962
1044
  activeTurnInputPreformatted: false,
963
1045
  agentMode: session.agentMode,
964
- approvalCursor: getDefaultApprovalCursor(),
1046
+ analyzingImages: 0,
1047
+ generatingImage: false,
1048
+ approvalCursor: 0,
965
1049
  approvalPrompt: null,
1050
+ approvalScrollOffset: 0,
966
1051
  autoYes: session.autoYes,
967
1052
  backgroundJobs: [],
968
1053
  busy: false,
1054
+ busyPausedAt: null,
969
1055
  busySince: null,
970
1056
  clockNow: Date.now(),
971
1057
  commandCursor: 0,
@@ -976,11 +1062,15 @@ function createInitialShellState(session, serverModels, debugUi) {
976
1062
  exiting: false,
977
1063
  input: '',
978
1064
  maxToolSteps: session.maxToolSteps,
1065
+ notificationsEnabled: readPreferences(session.env).notifications,
1066
+ terminalFocused: INITIAL_TERMINAL_FOCUS,
979
1067
  jobsPickerExpandedId: null,
980
1068
  jobsPickerIndex: 0,
981
1069
  jobsPickerOpen: false,
982
1070
  modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
983
1071
  modelPickerOpen: false,
1072
+ shortcutsOpen: false,
1073
+ transientStatus: null,
984
1074
  projectRoot: session.rootDir,
985
1075
  sessionId: session.sessionId,
986
1076
  showSessionId: debugUi.showSessionId,
@@ -993,6 +1083,7 @@ function createInitialShellState(session, serverModels, debugUi) {
993
1083
  resumePickerSessions: [],
994
1084
  transcriptScrollOffset: 0,
995
1085
  queuedMessage: null,
1086
+ turnMessages: [],
996
1087
  serverModels: serverModels.models,
997
1088
  sudoPrompt: null,
998
1089
  status: 'Ready',
@@ -1003,6 +1094,7 @@ function createInitialShellState(session, serverModels, debugUi) {
1003
1094
  tokenUsage: formatClientTokenUsage(null),
1004
1095
  transcript: [],
1005
1096
  turnCounter: Math.max(0, session.history.filter((entry) => entry.role === 'user').length),
1097
+ userInputPrompt: null,
1006
1098
  pastedChunks: [],
1007
1099
  imageAttachments: [],
1008
1100
  workingTools: [],
@@ -1019,7 +1111,6 @@ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
1019
1111
  const reasoningTokens = usageSummary?.reasoningTokens ?? 0;
1020
1112
  const cacheTokens = usageSummary?.cacheTokens ?? 0;
1021
1113
  const cacheWriteTokens = usageSummary?.cacheWriteTokens ?? 0;
1022
- const indexTokens = usageSummary?.indexTokens ?? 0;
1023
1114
  return [
1024
1115
  'Session tokens',
1025
1116
  `in ${formatTokenCount(inputTokens)}`,
@@ -1027,7 +1118,6 @@ export function formatClientTokenUsage(_responseTimeMs, usageSummary = null) {
1027
1118
  ...(reasoningTokens > 0 ? [`think ${formatTokenCount(reasoningTokens)}`] : []),
1028
1119
  `cache ${formatTokenCount(cacheTokens)}`,
1029
1120
  `write ${formatTokenCount(cacheWriteTokens)}`,
1030
- `index ${formatTokenCount(indexTokens)}`,
1031
1121
  ].join(' • ');
1032
1122
  }
1033
1123
  export function formatPromptDirectoryLabel(projectRoot, homeDir = process.env.HOME ?? '') {
@@ -1070,6 +1160,24 @@ function splitThinkingLines(text) {
1070
1160
  .map((part) => part.trim())
1071
1161
  .filter(Boolean));
1072
1162
  }
1163
+ const LIVE_STATUS_PANEL_MAX_CHARS = 72;
1164
+ const TOOL_PROGRESS_STATUS_PATTERN = /^(?:Tool:\s|Running\s\S+(?:\slocally)?\.\.\.$)/i;
1165
+ export function isToolProgressStatus(status) {
1166
+ return TOOL_PROGRESS_STATUS_PATTERN.test(String(status ?? '').trim());
1167
+ }
1168
+ function holdOrPickFallback(currentTitle) {
1169
+ return THINKING_FALLBACK_PHRASES.includes(currentTitle.trim())
1170
+ ? currentTitle
1171
+ : pickThinkingFallbackPhrase();
1172
+ }
1173
+ function liveStatusPanelTitle(status) {
1174
+ const text = String(status ?? '').trim();
1175
+ if (!text || text.includes('\n'))
1176
+ return '';
1177
+ if (isToolProgressStatus(text))
1178
+ return '';
1179
+ return text.length <= LIVE_STATUS_PANEL_MAX_CHARS ? text : '';
1180
+ }
1073
1181
  function thinkingPanelFromStatus(status) {
1074
1182
  const rawText = thinkingNoteFromStatus(status);
1075
1183
  if (!rawText)
@@ -1093,7 +1201,7 @@ function thinkingPanelFromStatus(status) {
1093
1201
  const bodyLines = rawLines.length > 1 && rawLines[0].length <= 72 ? rawLines.slice(1) : rawLines;
1094
1202
  return {
1095
1203
  title,
1096
- notes: splitThinkingLines(bodyLines.join('\n')).slice(-3),
1204
+ notes: splitThinkingLines(bodyLines.join('\n')).slice(0, 3),
1097
1205
  };
1098
1206
  }
1099
1207
  export const EXIT_CTRL_C_CONFIRM_MESSAGE = 'Press Ctrl+C again to quit.';
@@ -1101,16 +1209,6 @@ export const EXIT_CTRL_C_CONFIRM_MS = 3000;
1101
1209
  export function isExitConfirmActive(exitConfirmUntil, now = Date.now()) {
1102
1210
  return exitConfirmUntil != null && now < exitConfirmUntil;
1103
1211
  }
1104
- export function getInputCommandToken(input) {
1105
- const trimmed = String(input ?? '').trim();
1106
- if (!trimmed.startsWith('/'))
1107
- return '';
1108
- const firstSpaceIndex = trimmed.indexOf(' ');
1109
- const token = firstSpaceIndex === -1 ? trimmed : trimmed.slice(0, firstSpaceIndex);
1110
- if (token.indexOf('/', 1) !== -1)
1111
- return '';
1112
- return token;
1113
- }
1114
1212
  function shouldShowCommandPalette(state) {
1115
1213
  const trimmed = String(state.input ?? '').trim();
1116
1214
  return (!state.busy &&
@@ -1118,8 +1216,9 @@ function shouldShowCommandPalette(state) {
1118
1216
  !state.modelPickerOpen &&
1119
1217
  !state.resumePickerOpen &&
1120
1218
  trimmed.startsWith('/') &&
1121
- !trimmed.includes(' ') &&
1122
- getInputCommandToken(trimmed) !== '');
1219
+ !String(state.input ?? '').includes(' ') &&
1220
+ getInputCommandToken(trimmed) !== '' &&
1221
+ getSlashCommandSuggestions(trimmed).length > 0);
1123
1222
  }
1124
1223
  export function shouldRemountLiveFrameForComposerInputChange(current, nextInput) {
1125
1224
  const currentShowsCommands = shouldShowCommandPalette(current);
@@ -1142,9 +1241,6 @@ export function applySlashCommandSuggestion(currentInput, suggestion) {
1142
1241
  input: `${before}${suggestion.command}${after}`,
1143
1242
  };
1144
1243
  }
1145
- export function isExactSlashCommandToken(token) {
1146
- return SLASH_COMMANDS.some((command) => command.command === token);
1147
- }
1148
1244
  export function insertAtCursor(state, text) {
1149
1245
  const before = state.input.slice(0, state.cursor);
1150
1246
  const after = state.input.slice(state.cursor);
@@ -1223,26 +1319,37 @@ export function navigatePromptHistory(state, direction) {
1223
1319
  promptHistoryCursor: nextCursor,
1224
1320
  };
1225
1321
  }
1226
- function getDefaultApprovalCursor() {
1227
- const denyIndex = APPROVAL_OPTIONS.findIndex((option) => option.value === 'n');
1228
- return denyIndex === -1 ? 0 : denyIndex;
1229
- }
1230
- export function getNextApprovalCursor(currentIndex, direction) {
1231
- return (currentIndex + direction + APPROVAL_OPTIONS.length) % APPROVAL_OPTIONS.length;
1232
- }
1233
- export function getApprovalChoiceForCursor(cursor) {
1234
- return (APPROVAL_OPTIONS[Math.min(Math.max(cursor, 0), APPROVAL_OPTIONS.length - 1)]
1235
- ?.value ?? 'n');
1236
- }
1237
- export function resolveApprovalChoiceFromInput(input) {
1238
- const normalizedInput = String(input ?? '').trim().toLowerCase();
1239
- if (normalizedInput === 'y')
1240
- return 'y';
1241
- if (normalizedInput === 'a')
1242
- return 'a';
1243
- if (normalizedInput === 'n')
1244
- return 'n';
1245
- return null;
1322
+ export function getDefaultApprovalCursor(optionCount) {
1323
+ return Math.max(0, optionCount - 1);
1324
+ }
1325
+ export function getNextApprovalCursor(currentIndex, direction, optionCount) {
1326
+ if (optionCount <= 0)
1327
+ return 0;
1328
+ return (currentIndex + direction + optionCount) % optionCount;
1329
+ }
1330
+ export function pauseBusyClock(state, nowMs) {
1331
+ if (state.busySince === null || state.busyPausedAt !== null)
1332
+ return state;
1333
+ return { ...state, busyPausedAt: nowMs };
1334
+ }
1335
+ export function resumeBusyClock(state, nowMs) {
1336
+ if (state.busyPausedAt === null)
1337
+ return state;
1338
+ return {
1339
+ ...state,
1340
+ busyPausedAt: null,
1341
+ busySince: state.busySince === null
1342
+ ? null
1343
+ : state.busySince + Math.max(0, nowMs - state.busyPausedAt),
1344
+ };
1345
+ }
1346
+ export function busyElapsedMs(state, nowMs) {
1347
+ if (state.busySince === null)
1348
+ return null;
1349
+ return Math.max(0, (state.busyPausedAt ?? nowMs) - state.busySince);
1350
+ }
1351
+ export function busyElapsedSeconds(state, nowMs) {
1352
+ return Math.floor((busyElapsedMs(state, nowMs) ?? 0) / 1000);
1246
1353
  }
1247
1354
  export function buildModelPickerOptions(currentModelId, serverModels) {
1248
1355
  return serverModels.map((model) => ({
@@ -1280,27 +1387,33 @@ function appendCommandLog(state, text) {
1280
1387
  };
1281
1388
  }
1282
1389
  async function saveSessionBoth({ serverSessionClient, session, }) {
1390
+ if (!sessionHasUserMessage(session))
1391
+ return;
1283
1392
  saveSessionState(session);
1284
1393
  await serverSessionClient.save(session);
1285
1394
  }
1286
- export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, projectIndex, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1395
+ export async function runClientInteractive({ appendPromptHistory, authConfig, debugUi, serverModels, serverSessionClient, session, usageText, initialPrompt, }) {
1287
1396
  if (process.stdin.isTTY !== true) {
1288
1397
  throw new Error('stdin is not a terminal');
1289
1398
  }
1290
1399
  if (process.stdout.isTTY !== true) {
1291
1400
  throw new Error('stdout is not a terminal');
1292
1401
  }
1402
+ let fatalError = null;
1403
+ let signedOut = false;
1293
1404
  await withTuiMode(async () => {
1294
1405
  setBackgroundJobSession(session.sessionId);
1295
1406
  setScratchSession(session.sessionId);
1407
+ setImageStoreSession(session.sessionId);
1296
1408
  setTodoSession(session.sessionId);
1297
1409
  const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
1298
1410
  store.replaceTranscript(createSessionTranscript(session));
1299
1411
  let currentServerModels = serverModels;
1300
1412
  let sessionAutoYes = session.autoYes;
1301
1413
  let resolveDone = null;
1302
- let resolveApprovalChoice = null;
1414
+ let resolvePermissionDecision = null;
1303
1415
  let resolveSudoPassword = null;
1416
+ let pendingUserInput = null;
1304
1417
  let cleanupSudoPasswordPrompt = null;
1305
1418
  let sudoPasswordBuffer = '';
1306
1419
  const bridge = createRatatuiBridge();
@@ -1317,6 +1430,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1317
1430
  let latestUsageSummary = null;
1318
1431
  let pendingTurnEntries = [];
1319
1432
  let activeTurnAbort = null;
1433
+ let activeTurnImageAttachments = [];
1434
+ let activeServerTurnId = null;
1435
+ let unacknowledged = new Map();
1436
+ const blockedRows = new WeakMap();
1437
+ let newConversationInFlight = false;
1320
1438
  let todosTouchedThisTurn = false;
1321
1439
  const syncTodosState = () => {
1322
1440
  store.update((current) => ({ ...current, todos: listTodos() }));
@@ -1325,6 +1443,36 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1325
1443
  let exitCtrlCArmed = false;
1326
1444
  let exitCtrlCTimer = null;
1327
1445
  let transientStatusTimer = null;
1446
+ const THINKING_MIN_DWELL_MS = 3_000;
1447
+ let thinkingAppliedAt = 0;
1448
+ let pendingThinking = null;
1449
+ const applyThinking = (next) => {
1450
+ thinkingAppliedAt = Date.now();
1451
+ pendingThinking = null;
1452
+ store.update((current) => ({
1453
+ ...current,
1454
+ thinkingTitle: next.title || holdOrPickFallback(current.thinkingTitle),
1455
+ thinkingNotes: next.notes,
1456
+ }));
1457
+ };
1458
+ const queueThinkingUpdate = (next) => {
1459
+ if (Date.now() - thinkingAppliedAt >= THINKING_MIN_DWELL_MS) {
1460
+ applyThinking(next);
1461
+ return;
1462
+ }
1463
+ pendingThinking = next;
1464
+ };
1465
+ const flushPendingThinking = () => {
1466
+ if (!pendingThinking)
1467
+ return;
1468
+ if (Date.now() - thinkingAppliedAt < THINKING_MIN_DWELL_MS)
1469
+ return;
1470
+ applyThinking(pendingThinking);
1471
+ };
1472
+ const resetThinkingPacer = () => {
1473
+ thinkingAppliedAt = 0;
1474
+ pendingThinking = null;
1475
+ };
1328
1476
  const done = new Promise((resolve) => {
1329
1477
  resolveDone = resolve;
1330
1478
  });
@@ -1338,15 +1486,17 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1338
1486
  void remountTui();
1339
1487
  }, 0);
1340
1488
  };
1341
- const showTransientStatus = (status) => {
1489
+ const showTransientStatus = (transientStatus) => {
1342
1490
  if (transientStatusTimer) {
1343
1491
  clearTimeout(transientStatusTimer);
1344
1492
  transientStatusTimer = null;
1345
1493
  }
1346
- store.update((current) => ({ ...current, status }));
1494
+ store.update((current) => ({ ...current, transientStatus }));
1347
1495
  transientStatusTimer = setTimeout(() => {
1348
1496
  transientStatusTimer = null;
1349
- store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1497
+ store.update((current) => current.transientStatus === transientStatus
1498
+ ? { ...current, transientStatus: null }
1499
+ : current);
1350
1500
  }, 2500);
1351
1501
  };
1352
1502
  const terminalTitle = createTerminalTitleController({
@@ -1359,14 +1509,43 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1359
1509
  busy: state.busy,
1360
1510
  });
1361
1511
  };
1512
+ const notifier = backendFor();
1513
+ let notificationHandle = null;
1514
+ const projectLabel = basename(session.rootDir) || 'TheGitAI';
1515
+ const notifications = createNotificationQueue();
1516
+ const attention = createAttentionController({
1517
+ notify: (kind) => {
1518
+ notifications.run(async () => {
1519
+ notificationHandle = await notifier.post({
1520
+ title: projectLabel,
1521
+ body: kind === 'blocked'
1522
+ ? 'Waiting for your answer'
1523
+ : 'Finished \u2014 your turn',
1524
+ }, notificationHandle);
1525
+ });
1526
+ },
1527
+ withdraw: () => {
1528
+ notifications.run(async () => {
1529
+ notifier.withdraw(notificationHandle);
1530
+ notificationHandle = null;
1531
+ });
1532
+ },
1533
+ });
1534
+ const syncAttention = () => {
1535
+ const state = store.getState();
1536
+ attention.sync({
1537
+ awaitingAnswer: Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt),
1538
+ busy: state.busy,
1539
+ focused: state.terminalFocused,
1540
+ }, state.notificationsEnabled);
1541
+ };
1362
1542
  const renderCurrentFrame = () => {
1363
1543
  if (!tuiReady)
1364
1544
  return;
1365
1545
  const state = store.getState();
1366
1546
  syncTerminalTitle();
1367
- const elapsedSeconds = state.busySince
1368
- ? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
1369
- : 0;
1547
+ syncAttention();
1548
+ const elapsedSeconds = busyElapsedSeconds(state, Date.now());
1370
1549
  bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1371
1550
  };
1372
1551
  const remountTui = async () => {
@@ -1492,15 +1671,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1492
1671
  }
1493
1672
  };
1494
1673
  const dismissPendingApproval = () => {
1495
- if (!resolveApprovalChoice)
1674
+ if (!resolvePermissionDecision)
1496
1675
  return;
1497
- const pendingResolve = resolveApprovalChoice;
1498
- resolveApprovalChoice = null;
1499
- pendingResolve('n');
1676
+ const pendingResolve = resolvePermissionDecision;
1677
+ resolvePermissionDecision = null;
1678
+ pendingResolve({ kind: 'deny' });
1500
1679
  store.update((current) => ({
1501
- ...current,
1502
- approvalCursor: getDefaultApprovalCursor(),
1680
+ ...resumeBusyClock(current, Date.now()),
1681
+ approvalCursor: 0,
1503
1682
  approvalPrompt: null,
1683
+ approvalScrollOffset: 0,
1504
1684
  }));
1505
1685
  };
1506
1686
  const dismissPendingSudoPassword = () => {
@@ -1513,21 +1693,40 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1513
1693
  sudoPasswordBuffer = '';
1514
1694
  pendingResolve(null);
1515
1695
  store.update((current) => ({
1516
- ...current,
1696
+ ...resumeBusyClock(current, Date.now()),
1517
1697
  sudoPrompt: null,
1518
1698
  }));
1519
1699
  };
1700
+ const dismissPendingUserInput = () => {
1701
+ const pending = pendingUserInput;
1702
+ if (!pending)
1703
+ return;
1704
+ pendingUserInput = null;
1705
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1706
+ const error = new Error('Turn cancelled.');
1707
+ error.name = 'AbortError';
1708
+ pending.reject(error);
1709
+ store.update((current) => ({
1710
+ ...resumeBusyClock(current, Date.now()),
1711
+ status: current.userInputPrompt?.returnStatus ?? current.status,
1712
+ userInputPrompt: null,
1713
+ }));
1714
+ };
1520
1715
  const cancelActiveTurn = () => {
1521
- if (!store.getState().busy)
1716
+ if (!store.getState().busy || newConversationInFlight)
1522
1717
  return;
1523
1718
  disarmExitConfirm();
1524
1719
  dismissPendingApproval();
1525
1720
  dismissPendingSudoPassword();
1721
+ dismissPendingUserInput();
1526
1722
  activeTurnGeneration += 1;
1723
+ resetThinkingPacer();
1527
1724
  activeTurnAbort?.abort();
1528
1725
  activeTurnAbort = null;
1529
1726
  cancelActiveCommand();
1530
1727
  const queued = store.getState().queuedMessage;
1728
+ const restoredAttachments = composerAttachmentsAfterCancel(queued ? queued.imageAttachments : [], activeTurnImageAttachments);
1729
+ activeTurnImageAttachments = [];
1531
1730
  const cancelledEntries = [
1532
1731
  ...takePendingTurnEntries(),
1533
1732
  {
@@ -1541,20 +1740,23 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1541
1740
  activeTurnInput: '',
1542
1741
  activeTurnInputPreformatted: false,
1543
1742
  busy: false,
1743
+ busyPausedAt: null,
1544
1744
  busySince: null,
1545
1745
  commandLog: [],
1546
1746
  cursor: queued ? queued.body.length : current.cursor,
1547
- imageAttachments: queued ? queued.imageAttachments : [],
1747
+ imageAttachments: restoredAttachments,
1548
1748
  input: queued ? queued.body : current.input,
1549
1749
  pastedChunks: queued ? queued.pastedChunks : current.pastedChunks,
1550
1750
  promptHistoryCursor: queued ? null : current.promptHistoryCursor,
1551
1751
  promptHistoryDraft: queued ? '' : current.promptHistoryDraft,
1552
1752
  queuedMessage: null,
1753
+ recoveryStatus: '',
1553
1754
  status: 'Ready',
1554
1755
  thinkingTitle: '',
1555
1756
  thinkingNotes: [],
1757
+ turnMessages: [],
1556
1758
  workingTools: [],
1557
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
1759
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
1558
1760
  }));
1559
1761
  appendStaticEntries(cancelledEntries);
1560
1762
  lastTurnStartedAt = null;
@@ -1562,14 +1764,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1562
1764
  scheduleLiveFrameRemount();
1563
1765
  void remountTui();
1564
1766
  };
1565
- const saveActiveSession = async () => {
1566
- try {
1567
- await saveSessionBoth({ serverSessionClient, session });
1568
- }
1569
- catch (error) {
1570
- appendError(`Session save failed: ${error.message}`);
1571
- }
1572
- };
1573
1767
  const syncShellStateFromSession = () => {
1574
1768
  store.update((current) => ({
1575
1769
  ...current,
@@ -1600,6 +1794,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1600
1794
  setAgentMode(nextAgentMode(session.agentMode));
1601
1795
  scheduleLiveFrameRemount();
1602
1796
  };
1797
+ const reportAgentModeLocked = () => {
1798
+ showTransientStatus(AGENT_MODE_LOCKED_STATUS);
1799
+ };
1603
1800
  const clearLiveFrameRemountTimer = () => {
1604
1801
  if (!liveFrameRemountTimer)
1605
1802
  return;
@@ -1648,6 +1845,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1648
1845
  }
1649
1846
  dismissPendingApproval();
1650
1847
  dismissPendingSudoPassword();
1848
+ dismissPendingUserInput();
1651
1849
  exiting = true;
1652
1850
  killAllBackgroundJobs();
1653
1851
  store.update((current) => ({
@@ -1661,6 +1859,41 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1661
1859
  resolveDone?.();
1662
1860
  });
1663
1861
  };
1862
+ const exitForAuthenticationError = (error) => {
1863
+ if (!isAuthenticationError(error))
1864
+ return false;
1865
+ clearCliAuthConfig(session.env);
1866
+ if (sessionHasUserMessage(session)) {
1867
+ saveSessionState(session);
1868
+ }
1869
+ fatalError = new Error(authenticationErrorMessage(error));
1870
+ requestExit();
1871
+ return true;
1872
+ };
1873
+ const saveActiveSession = async () => {
1874
+ try {
1875
+ await saveSessionBoth({ serverSessionClient, session });
1876
+ return true;
1877
+ }
1878
+ catch (error) {
1879
+ if (exitForAuthenticationError(error))
1880
+ return false;
1881
+ appendError(`Session save failed: ${error.message}`);
1882
+ return true;
1883
+ }
1884
+ };
1885
+ const saveActiveSessionOrAbort = async () => {
1886
+ try {
1887
+ await saveSessionBoth({ serverSessionClient, session });
1888
+ return true;
1889
+ }
1890
+ catch (error) {
1891
+ if (exitForAuthenticationError(error))
1892
+ return false;
1893
+ appendError(`Session save failed: ${error.message}`);
1894
+ return false;
1895
+ }
1896
+ };
1664
1897
  const openSudoPasswordPrompt = (command, prompt, signal) => new Promise((resolve) => {
1665
1898
  if (exiting || signal?.aborted) {
1666
1899
  resolve(null);
@@ -1678,7 +1911,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1678
1911
  cleanupSudoPasswordPrompt = null;
1679
1912
  }
1680
1913
  store.update((current) => ({
1681
- ...current,
1914
+ ...pauseBusyClock(current, Date.now()),
1682
1915
  sudoPrompt: {
1683
1916
  command,
1684
1917
  passwordLength: 0,
@@ -1698,7 +1931,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1698
1931
  cleanupSudoPasswordPrompt = null;
1699
1932
  sudoPasswordBuffer = '';
1700
1933
  store.update((current) => ({
1701
- ...current,
1934
+ ...resumeBusyClock(current, Date.now()),
1702
1935
  sudoPrompt: null,
1703
1936
  status: current.sudoPrompt?.returnStatus ?? current.status,
1704
1937
  }));
@@ -1712,7 +1945,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1712
1945
  cleanupSudoPasswordPrompt = null;
1713
1946
  sudoPasswordBuffer = '';
1714
1947
  store.update((current) => ({
1715
- ...current,
1948
+ ...resumeBusyClock(current, Date.now()),
1716
1949
  sudoPrompt: null,
1717
1950
  status: current.sudoPrompt?.returnStatus ?? current.status,
1718
1951
  }));
@@ -1732,38 +1965,95 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1732
1965
  : null,
1733
1966
  }));
1734
1967
  };
1735
- const openApprovalPrompt = (title, body, options = {}) => new Promise((resolve) => {
1736
- if (exiting) {
1737
- resolve('n');
1968
+ const openUserInputPrompt = (questions, signal) => new Promise((resolve, reject) => {
1969
+ if (exiting || signal?.aborted) {
1970
+ const error = new Error('Turn cancelled.');
1971
+ error.name = 'AbortError';
1972
+ reject(error);
1738
1973
  return;
1739
1974
  }
1740
- resolveApprovalChoice = resolve;
1975
+ const onAbort = () => dismissPendingUserInput();
1976
+ pendingUserInput = {
1977
+ questions,
1978
+ resolve,
1979
+ reject,
1980
+ signal,
1981
+ onAbort,
1982
+ };
1983
+ signal?.addEventListener('abort', onAbort, { once: true });
1741
1984
  store.update((current) => ({
1742
- ...current,
1743
- approvalCursor: getDefaultApprovalCursor(),
1985
+ ...pauseBusyClock(current, Date.now()),
1986
+ ...closedJobsPickerFields(current),
1987
+ status: 'Waiting for your input',
1988
+ userInputPrompt: createUserInputPromptState(questions, statusBehindJobsPicker(current)),
1989
+ }));
1990
+ scheduleLiveFrameRemount();
1991
+ });
1992
+ const handleInlineUserInput = async (result) => {
1993
+ const pending = pendingUserInput;
1994
+ const prompt = store.getState().userInputPrompt;
1995
+ if (!pending || !prompt)
1996
+ return;
1997
+ pendingUserInput = null;
1998
+ pending.signal?.removeEventListener('abort', pending.onAbort);
1999
+ store.update((current) => ({
2000
+ ...resumeBusyClock(current, Date.now()),
2001
+ status: prompt.returnStatus,
2002
+ userInputPrompt: null,
2003
+ }));
2004
+ appendTurnAwareEntry({
2005
+ body: formatUserInputTranscript(pending.questions, result),
2006
+ kind: result.status === 'submitted' ? 'user' : 'system',
2007
+ title: result.status === 'submitted' ? 'Your answers' : 'Question',
2008
+ });
2009
+ pending.resolve(result);
2010
+ scheduleLiveFrameRemount();
2011
+ };
2012
+ const openApprovalPrompt = (request, signal) => new Promise((resolve) => {
2013
+ const deny = { kind: 'deny' };
2014
+ if (exiting || signal?.aborted) {
2015
+ resolve(deny);
2016
+ return;
2017
+ }
2018
+ const onAbort = () => dismissPendingApproval();
2019
+ signal?.addEventListener('abort', onAbort, { once: true });
2020
+ resolvePermissionDecision = (decision) => {
2021
+ signal?.removeEventListener('abort', onAbort);
2022
+ resolve(decision);
2023
+ };
2024
+ store.update((current) => ({
2025
+ ...pauseBusyClock(current, Date.now()),
2026
+ ...closedJobsPickerFields(current),
2027
+ approvalCursor: getDefaultApprovalCursor(request.options.length),
2028
+ approvalOpenedAt: Date.now(),
2029
+ approvalScrollOffset: 0,
1744
2030
  approvalPrompt: {
1745
- title,
1746
- body,
1747
- diffPreview: options.diff && options.filePath
1748
- ? parseDiffPreview(options.diff)
2031
+ title: request.title,
2032
+ body: request.body,
2033
+ diffPreview: request.diff && request.filePath
2034
+ ? parseDiffPreview(request.diff)
1749
2035
  : undefined,
1750
- filePath: options.filePath,
1751
- returnStatus: current.status,
2036
+ filePath: request.filePath,
2037
+ options: request.options,
2038
+ returnStatus: statusBehindJobsPicker(current),
1752
2039
  },
1753
- status: title,
2040
+ status: request.title,
1754
2041
  }));
1755
2042
  });
1756
- const handleInlineApprovalChoice = async (choice) => {
2043
+ const handleInlineApprovalChoice = async (index) => {
1757
2044
  const current = store.getState();
1758
- const pendingResolve = resolveApprovalChoice;
1759
- resolveApprovalChoice = null;
2045
+ const options = current.approvalPrompt?.options ?? [];
2046
+ const chosen = options[index]?.decision ?? { kind: 'deny' };
2047
+ const pendingResolve = resolvePermissionDecision;
2048
+ resolvePermissionDecision = null;
1760
2049
  store.update((next) => ({
1761
- ...next,
1762
- approvalCursor: getDefaultApprovalCursor(),
2050
+ ...resumeBusyClock(next, Date.now()),
2051
+ approvalCursor: getDefaultApprovalCursor(options.length),
1763
2052
  approvalPrompt: null,
2053
+ approvalScrollOffset: 0,
1764
2054
  status: current.approvalPrompt?.returnStatus ?? next.status,
1765
2055
  }));
1766
- pendingResolve?.(choice);
2056
+ pendingResolve?.(chosen);
1767
2057
  };
1768
2058
  const refreshServerModels = async () => {
1769
2059
  currentServerModels = await models.fetchServerModels({ config: authConfig });
@@ -1797,7 +2087,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1797
2087
  selectedModelId: selected,
1798
2088
  serverModels: fresh,
1799
2089
  });
1800
- await saveActiveSession();
2090
+ if (!(await saveActiveSession()))
2091
+ return;
1801
2092
  syncShellStateFromSession();
1802
2093
  appendStaticEntry({
1803
2094
  body: `Switched to ${formatModelLabel(selected, fresh.models)}. Conversation history preserved.`,
@@ -1805,24 +2096,35 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1805
2096
  title: 'Model',
1806
2097
  });
1807
2098
  };
2099
+ const toggleNotifications = () => {
2100
+ const enabled = !store.getState().notificationsEnabled;
2101
+ store.update((current) => ({ ...current, notificationsEnabled: enabled }));
2102
+ try {
2103
+ writePreferences({ ...readPreferences(session.env), notifications: enabled }, session.env);
2104
+ }
2105
+ catch {
2106
+ }
2107
+ showTransientStatus(enabled ? 'Notifications on' : 'Notifications off');
2108
+ };
1808
2109
  const openJobsPicker = () => {
2110
+ if (store.getState().jobsPickerOpen)
2111
+ return;
1809
2112
  syncBackgroundJobsState();
1810
2113
  store.update((current) => ({
1811
2114
  ...current,
1812
2115
  commandCursor: 0,
1813
- cursor: 0,
1814
- input: '',
1815
2116
  jobsPickerExpandedId: null,
1816
2117
  jobsPickerIndex: 0,
1817
2118
  jobsPickerOpen: true,
2119
+ jobsPickerReturnStatus: current.status,
1818
2120
  status: 'Background jobs',
1819
2121
  }));
2122
+ scheduleLiveFrameRemount();
1820
2123
  };
1821
2124
  const appendJobOutputEntry = async (jobId) => {
1822
2125
  const id = jobId.trim();
1823
2126
  await collectBackgroundJobUiOutputMutations({
1824
2127
  session,
1825
- projectIndex,
1826
2128
  jobId: id,
1827
2129
  });
1828
2130
  const job = listBackgroundJobs().find((candidate) => candidate.id === id);
@@ -1848,32 +2150,51 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1848
2150
  const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
1849
2151
  return current.backgroundJobs[index]?.id ?? null;
1850
2152
  };
2153
+ const deferredJobMutationIds = new Set();
2154
+ const drainDeferredJobMutations = async () => {
2155
+ if (deferredJobMutationIds.size === 0)
2156
+ return;
2157
+ const pending = [...deferredJobMutationIds];
2158
+ deferredJobMutationIds.clear();
2159
+ for (const jobId of pending) {
2160
+ await collectBackgroundJobUiOutputMutations({ session, jobId });
2161
+ }
2162
+ syncBackgroundJobsState();
2163
+ };
1851
2164
  const handleJobsPickerOutput = async () => {
1852
2165
  const jobId = selectedJobsPickerId();
1853
2166
  if (!jobId)
1854
2167
  return;
1855
- await collectBackgroundJobUiOutputMutations({
1856
- session,
1857
- projectIndex,
1858
- jobId,
1859
- });
1860
- syncBackgroundJobsState();
2168
+ if (store.getState().busy) {
2169
+ deferredJobMutationIds.add(jobId);
2170
+ }
2171
+ else {
2172
+ await collectBackgroundJobUiOutputMutations({
2173
+ session,
2174
+ jobId,
2175
+ });
2176
+ }
1861
2177
  store.update((current) => ({
1862
2178
  ...current,
1863
2179
  jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
1864
2180
  }));
2181
+ syncBackgroundJobsState();
1865
2182
  };
1866
2183
  const handleJobsPickerKill = async () => {
1867
2184
  const jobId = selectedJobsPickerId();
1868
2185
  if (!jobId)
1869
2186
  return;
1870
2187
  const killed = await killBackgroundJob(jobId);
1871
- await collectBackgroundJobUiKillMutations({
1872
- session,
1873
- projectIndex,
1874
- jobId,
1875
- result: killed,
1876
- });
2188
+ if (store.getState().busy) {
2189
+ deferredJobMutationIds.add(jobId);
2190
+ }
2191
+ else {
2192
+ await collectBackgroundJobUiKillMutations({
2193
+ session,
2194
+ jobId,
2195
+ result: killed,
2196
+ });
2197
+ }
1877
2198
  syncBackgroundJobsState();
1878
2199
  if (!killed.ok) {
1879
2200
  appendError(killed.error ?? 'Background job kill failed.');
@@ -1916,6 +2237,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1916
2237
  await switchToModel(selected.id);
1917
2238
  }
1918
2239
  catch (error) {
2240
+ if (exitForAuthenticationError(error))
2241
+ return;
1919
2242
  appendError(error.message);
1920
2243
  }
1921
2244
  };
@@ -1965,15 +2288,24 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1965
2288
  store.update((next) => ({
1966
2289
  ...next,
1967
2290
  busy: true,
2291
+ busyPausedAt: null,
1968
2292
  busySince: resumeStartedAt,
1969
2293
  clockNow: resumeStartedAt,
1970
2294
  status: 'Loading session...',
1971
2295
  }));
1972
2296
  try {
1973
2297
  const snapshot = await loadInteractiveSession(selected.id);
1974
- applySessionSnapshot(session, snapshot);
2298
+ const requestedModelId = Number(snapshot.modelId);
2299
+ const knownModels = currentServerModels.models.some((model) => model.id === requestedModelId)
2300
+ ? currentServerModels
2301
+ : await refreshServerModels();
2302
+ const { modelId: resolvedModelId, fellBack } = models.resolveResumedSessionModel(requestedModelId, knownModels);
2303
+ applySessionSnapshot(session, snapshot, {
2304
+ modelOverride: fellBack ? resolvedModelId : undefined,
2305
+ });
1975
2306
  setBackgroundJobSession(session.sessionId);
1976
2307
  setScratchSession(session.sessionId);
2308
+ setImageStoreSession(session.sessionId);
1977
2309
  syncBackgroundJobsState();
1978
2310
  setTodoSession(session.sessionId);
1979
2311
  syncTodosState();
@@ -1981,6 +2313,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1981
2313
  store.update((next) => ({
1982
2314
  ...next,
1983
2315
  busy: false,
2316
+ busyPausedAt: null,
1984
2317
  busySince: null,
1985
2318
  resumePickerFilter: '',
1986
2319
  resumePickerIndex: 0,
@@ -1994,14 +2327,26 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1994
2327
  kind: 'system',
1995
2328
  title: 'Session',
1996
2329
  },
2330
+ ...(fellBack
2331
+ ? [
2332
+ {
2333
+ body: `This session's model is no longer available — switched to ${formatModelLabel(resolvedModelId, knownModels.models)}. Conversation history preserved.`,
2334
+ kind: 'system',
2335
+ title: 'Model',
2336
+ },
2337
+ ]
2338
+ : []),
1997
2339
  ...buildTranscriptFromSessionHistory(session.history),
1998
2340
  ]);
1999
2341
  await remountTui();
2000
2342
  }
2001
2343
  catch (error) {
2344
+ if (exitForAuthenticationError(error))
2345
+ return;
2002
2346
  store.update((next) => ({
2003
2347
  ...next,
2004
2348
  busy: false,
2349
+ busyPausedAt: null,
2005
2350
  busySince: null,
2006
2351
  resumePickerFilter: '',
2007
2352
  resumePickerIndex: 0,
@@ -2025,6 +2370,127 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2025
2370
  }));
2026
2371
  await handleSubmit(queued.body);
2027
2372
  };
2373
+ let turnMessageCounter = 0;
2374
+ const upsertTurnMessage = (id, patch) => {
2375
+ store.update((current) => ({
2376
+ ...current,
2377
+ turnMessages: current.turnMessages.map((message) => message.id === id ? { ...message, ...patch } : message),
2378
+ }));
2379
+ };
2380
+ const recoverUnacknowledgedMessages = (autoSubmits = true) => {
2381
+ if (unacknowledged.size === 0)
2382
+ return;
2383
+ const stranded = [...unacknowledged.values()];
2384
+ unacknowledged = new Map();
2385
+ for (const { row } of stranded) {
2386
+ upsertTurnMessage(row, {
2387
+ state: 'queued',
2388
+ note: autoSubmits
2389
+ ? 'not read — sending as the next prompt'
2390
+ : 'not read — left in the composer',
2391
+ });
2392
+ }
2393
+ const existing = store.getState().queuedMessage;
2394
+ const combined = [
2395
+ ...stranded.map((entry) => entry.queued),
2396
+ ...(existing ? [existing] : []),
2397
+ ];
2398
+ const body = combined
2399
+ .map((entry) => entry.body)
2400
+ .filter((entry) => entry.trim())
2401
+ .join('\n\n');
2402
+ if (!body.trim())
2403
+ return;
2404
+ scheduleLiveFrameRemount();
2405
+ store.update((current) => ({
2406
+ ...current,
2407
+ queuedMessage: {
2408
+ body,
2409
+ imageAttachments: combined.flatMap((entry) => entry.imageAttachments),
2410
+ pastedChunks: combined.flatMap((entry) => entry.pastedChunks),
2411
+ },
2412
+ }));
2413
+ };
2414
+ const fireQueuedMessage = async () => {
2415
+ const state = store.getState();
2416
+ const queued = state.queuedMessage;
2417
+ if (!queued || !state.busy || exiting)
2418
+ return;
2419
+ const text = (queued.pastedChunks.length
2420
+ ? expandPastedChunks(queued.body, queued.pastedChunks)
2421
+ : queued.body).trim();
2422
+ const images = queued.imageAttachments.filter((attachment) => queued.body.includes(`[Image #${attachment.index}]`));
2423
+ if (!text && images.length === 0)
2424
+ return;
2425
+ const turnId = activeServerTurnId;
2426
+ const blockedReason = !turnId ? 'waiting for the turn to end' : null;
2427
+ const existingRow = blockedRows.get(queued);
2428
+ if (existingRow !== undefined) {
2429
+ upsertTurnMessage(existingRow, {
2430
+ state: 'queued',
2431
+ ...(blockedReason ? { note: blockedReason } : {}),
2432
+ });
2433
+ if (blockedReason || !turnId)
2434
+ return;
2435
+ }
2436
+ const id = existingRow ?? ++turnMessageCounter;
2437
+ const messageId = `m${id}_${Date.now().toString(36)}`;
2438
+ if (existingRow === undefined) {
2439
+ scheduleLiveFrameRemount();
2440
+ store.update((current) => ({
2441
+ ...current,
2442
+ turnMessages: [
2443
+ ...current.turnMessages,
2444
+ {
2445
+ id,
2446
+ text,
2447
+ state: blockedReason ? 'queued' : 'sending',
2448
+ ...(blockedReason ? { note: blockedReason } : {}),
2449
+ },
2450
+ ],
2451
+ }));
2452
+ }
2453
+ if (blockedReason || !turnId) {
2454
+ blockedRows.set(queued, id);
2455
+ return;
2456
+ }
2457
+ blockedRows.delete(queued);
2458
+ unacknowledged.set(messageId, { row: id, queued });
2459
+ scheduleLiveFrameRemount();
2460
+ store.update((current) => current.queuedMessage === queued
2461
+ ? { ...current, queuedMessage: null }
2462
+ : current);
2463
+ queueTurnEntry({
2464
+ body: text,
2465
+ kind: 'user',
2466
+ preformatted: queued.pastedChunks.length > 0,
2467
+ title: 'You · sent mid-turn',
2468
+ });
2469
+ const rollback = (note) => {
2470
+ if (!unacknowledged.delete(messageId))
2471
+ return;
2472
+ upsertTurnMessage(id, { state: 'queued', note });
2473
+ scheduleLiveFrameRemount();
2474
+ store.update((current) => current.queuedMessage ? current : { ...current, queuedMessage: queued });
2475
+ };
2476
+ let outcome;
2477
+ try {
2478
+ outcome = await chat.postInterjection({
2479
+ config: authConfig,
2480
+ turnId,
2481
+ text,
2482
+ messageId,
2483
+ imageAttachments: images,
2484
+ });
2485
+ }
2486
+ catch (error) {
2487
+ rollback(`not sent — ${error?.message ?? 'request failed'}`);
2488
+ return;
2489
+ }
2490
+ if (outcome === 'stale') {
2491
+ rollback('turn ended — sending as the next prompt');
2492
+ }
2493
+ };
2028
2494
  const handleSubmit = async (rawInput) => {
2029
2495
  const chunks = store.getState().pastedChunks;
2030
2496
  const expanded = chunks.length
@@ -2036,17 +2502,39 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2036
2502
  if (!input || exiting)
2037
2503
  return;
2038
2504
  if (input.startsWith('/')) {
2505
+ if (isJobsPickerCommand(input)) {
2506
+ store.update((current) => ({
2507
+ ...current,
2508
+ cursor: 0,
2509
+ input: '',
2510
+ pastedChunks: [],
2511
+ }));
2512
+ openJobsPicker();
2513
+ return;
2514
+ }
2515
+ if (input === '/notify') {
2516
+ store.update((current) => ({
2517
+ ...current,
2518
+ cursor: 0,
2519
+ input: '',
2520
+ pastedChunks: [],
2521
+ }));
2522
+ toggleNotifications();
2523
+ return;
2524
+ }
2039
2525
  appendTurnAwareEntry({
2040
- body: "Slash commands can't be queued while a turn is running.",
2526
+ body: "Slash commands can't be queued while a turn is running. " +
2527
+ 'Ctrl+B opens background jobs, Ctrl+G toggles notifications.',
2041
2528
  kind: 'system',
2042
2529
  title: 'Queued',
2043
2530
  });
2044
2531
  return;
2045
2532
  }
2046
2533
  const pending = store.getState();
2534
+ const body = String(rawInput ?? '');
2047
2535
  const snapshot = {
2048
- body: String(rawInput ?? ''),
2049
- imageAttachments: pending.imageAttachments,
2536
+ body,
2537
+ imageAttachments: pending.imageAttachments.filter((attachment) => body.includes(`[Image #${attachment.index}]`)),
2050
2538
  pastedChunks: pending.pastedChunks,
2051
2539
  };
2052
2540
  scheduleLiveFrameRemount();
@@ -2061,11 +2549,14 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2061
2549
  }));
2062
2550
  return;
2063
2551
  }
2064
- const imageAttachments = store.getState().imageAttachments;
2552
+ const imageAttachments = store
2553
+ .getState()
2554
+ .imageAttachments.filter((attachment) => input.includes(`[Image #${attachment.index}]`));
2065
2555
  scheduleLiveFrameRemount();
2066
2556
  store.update((current) => ({
2067
2557
  ...current,
2068
2558
  cursor: 0,
2559
+ imageAttachments: [],
2069
2560
  input: '',
2070
2561
  pastedChunks: [],
2071
2562
  promptHistory: appendPromptToHistory(current.promptHistory, input),
@@ -2081,6 +2572,22 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2081
2572
  requestExit();
2082
2573
  return;
2083
2574
  }
2575
+ if (input === '/logout') {
2576
+ store.update((current) => ({
2577
+ ...current,
2578
+ busy: true,
2579
+ status: 'Signing out...',
2580
+ }));
2581
+ try {
2582
+ await logoutFromServer({ config: authConfig });
2583
+ }
2584
+ catch {
2585
+ }
2586
+ clearCliAuthConfig(session.env);
2587
+ signedOut = true;
2588
+ requestExit();
2589
+ return;
2590
+ }
2084
2591
  if (input === '/help') {
2085
2592
  appendStaticEntry({
2086
2593
  body: formatInteractiveHelpText(),
@@ -2097,6 +2604,15 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2097
2604
  });
2098
2605
  return;
2099
2606
  }
2607
+ if (input === '/report') {
2608
+ const opened = await openUrl(REPORT_ISSUE_URL);
2609
+ appendStaticEntries(buildReportTranscriptEntries(opened));
2610
+ return;
2611
+ }
2612
+ if (input === '/notify') {
2613
+ toggleNotifications();
2614
+ return;
2615
+ }
2100
2616
  if (input === '/jobs' || input.startsWith('/jobs ')) {
2101
2617
  const jobsArgs = input.slice('/jobs'.length).trim();
2102
2618
  if (!jobsArgs) {
@@ -2109,7 +2625,6 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2109
2625
  const killed = await killBackgroundJob(jobId);
2110
2626
  await collectBackgroundJobUiKillMutations({
2111
2627
  session,
2112
- projectIndex,
2113
2628
  jobId,
2114
2629
  result: killed,
2115
2630
  });
@@ -2151,14 +2666,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2151
2666
  });
2152
2667
  }
2153
2668
  catch (error) {
2669
+ if (exitForAuthenticationError(error))
2670
+ return;
2154
2671
  appendError(error.message);
2155
2672
  }
2156
2673
  finally {
2157
- store.update((current) => ({
2158
- ...current,
2159
- busy: false,
2160
- status: 'Ready',
2161
- }));
2674
+ if (!exiting) {
2675
+ store.update((current) => ({
2676
+ ...current,
2677
+ busy: false,
2678
+ status: 'Ready',
2679
+ }));
2680
+ }
2162
2681
  }
2163
2682
  return;
2164
2683
  }
@@ -2172,14 +2691,18 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2172
2691
  await openResumePicker();
2173
2692
  }
2174
2693
  catch (error) {
2694
+ if (exitForAuthenticationError(error))
2695
+ return;
2175
2696
  appendError(error.message);
2176
2697
  }
2177
2698
  finally {
2178
- store.update((current) => ({
2179
- ...current,
2180
- busy: false,
2181
- status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2182
- }));
2699
+ if (!exiting) {
2700
+ store.update((current) => ({
2701
+ ...current,
2702
+ busy: false,
2703
+ status: current.resumePickerOpen ? 'Select a session to resume' : 'Ready',
2704
+ }));
2705
+ }
2183
2706
  }
2184
2707
  return;
2185
2708
  }
@@ -2199,37 +2722,77 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2199
2722
  }
2200
2723
  }
2201
2724
  catch (error) {
2725
+ if (exitForAuthenticationError(error))
2726
+ return;
2202
2727
  appendError(error.message);
2203
2728
  }
2204
2729
  finally {
2730
+ if (!exiting) {
2731
+ store.update((current) => ({
2732
+ ...current,
2733
+ busy: false,
2734
+ status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2735
+ }));
2736
+ }
2737
+ }
2738
+ return;
2739
+ }
2740
+ if (input === '/new') {
2741
+ newConversationInFlight = true;
2742
+ store.update((current) => ({
2743
+ ...current,
2744
+ busy: true,
2745
+ busyPausedAt: null,
2746
+ busySince: Date.now(),
2747
+ imageAttachments: [],
2748
+ queuedMessage: null,
2749
+ status: 'Starting a new conversation...',
2750
+ }));
2751
+ if (!(await saveActiveSessionOrAbort())) {
2752
+ newConversationInFlight = false;
2205
2753
  store.update((current) => ({
2206
2754
  ...current,
2207
2755
  busy: false,
2208
- status: current.modelPickerOpen ? 'Select a model' : 'Ready',
2756
+ busyPausedAt: null,
2757
+ busySince: null,
2758
+ status: 'Ready',
2209
2759
  }));
2760
+ await flushQueuedMessage();
2761
+ return;
2210
2762
  }
2211
- return;
2212
- }
2213
- if (input === '/clear') {
2214
- clearConversation(session);
2763
+ startNewConversation(session);
2764
+ setBackgroundJobSession(session.sessionId);
2765
+ setScratchSession(session.sessionId);
2766
+ setImageStoreSession(session.sessionId);
2767
+ setTodoSession(session.sessionId);
2215
2768
  clearTodos();
2769
+ syncBackgroundJobsState();
2216
2770
  syncTodosState();
2217
2771
  latestUsageSummary = null;
2218
- await saveActiveSession();
2219
2772
  store.replaceTranscript([
2220
2773
  {
2221
- body: 'Conversation cleared.',
2774
+ body: 'Started a new conversation. The previous session remains saved.',
2222
2775
  kind: 'system',
2223
- title: 'System',
2776
+ title: 'Session',
2224
2777
  },
2225
2778
  ]);
2779
+ await saveActiveSession();
2226
2780
  syncShellStateFromSession();
2227
- store.update((current) => ({ ...current, queuedMessage: null }));
2781
+ newConversationInFlight = false;
2782
+ store.update((current) => ({
2783
+ ...current,
2784
+ busy: false,
2785
+ busyPausedAt: null,
2786
+ busySince: null,
2787
+ status: 'Ready',
2788
+ }));
2228
2789
  await remountTui();
2790
+ await flushQueuedMessage();
2229
2791
  return;
2230
2792
  }
2231
2793
  latestUsageSummary = null;
2232
2794
  disarmExitConfirm();
2795
+ unacknowledged = new Map();
2233
2796
  const turnStartedAt = Date.now();
2234
2797
  const turnGeneration = ++activeTurnGeneration;
2235
2798
  const turnAbort = new AbortController();
@@ -2246,36 +2809,63 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2246
2809
  };
2247
2810
  pendingTurnEntries = [];
2248
2811
  queueTurnEntry(userEntry);
2812
+ resetThinkingPacer();
2813
+ activeTurnImageAttachments = imageAttachments;
2249
2814
  store.update((current) => ({
2250
2815
  ...current,
2251
2816
  activeTurnInput: input,
2252
2817
  activeTurnInputPreformatted: preformatted,
2818
+ analyzingImages: 0,
2819
+ generatingImage: false,
2253
2820
  busy: true,
2821
+ busyPausedAt: null,
2254
2822
  busySince: turnStartedAt,
2255
2823
  clockNow: turnStartedAt,
2824
+ recoveryStatus: '',
2256
2825
  status: 'Running turn...',
2257
2826
  thinkingTitle: '',
2258
2827
  thinkingNotes: [],
2259
2828
  transcriptScrollOffset: 0,
2260
2829
  tokenUsage: formatClientTokenUsage(0, latestUsageSummary),
2261
2830
  turnCounter: current.turnCounter + 1,
2831
+ turnMessages: [],
2262
2832
  workingTools: [],
2263
2833
  }));
2264
2834
  try {
2265
2835
  const result = await chat.sendServerUserMessage({
2266
2836
  config: authConfig,
2267
- projectIndex,
2268
2837
  session,
2269
2838
  input,
2270
2839
  imageAttachments,
2271
2840
  signal: turnAbort.signal,
2841
+ onTurnStart: (serverTurnId) => {
2842
+ if (turnGeneration !== activeTurnGeneration)
2843
+ return;
2844
+ activeServerTurnId = serverTurnId;
2845
+ },
2846
+ onInterjectionDelivered: (event) => {
2847
+ if (turnGeneration !== activeTurnGeneration)
2848
+ return;
2849
+ for (const messageId of event.messageIds ?? []) {
2850
+ const pending = unacknowledged.get(messageId);
2851
+ if (!pending)
2852
+ continue;
2853
+ unacknowledged.delete(messageId);
2854
+ upsertTurnMessage(pending.row, {
2855
+ state: 'delivered',
2856
+ note: undefined,
2857
+ });
2858
+ }
2859
+ },
2272
2860
  });
2273
2861
  if (turnGeneration !== activeTurnGeneration)
2274
2862
  return;
2275
2863
  latestUsageSummary = result.usageSummary ?? null;
2276
2864
  const turnEntries = takePendingTurnEntries();
2277
- await saveActiveSession();
2865
+ if (!(await saveActiveSession()))
2866
+ return;
2278
2867
  syncShellStateFromSession();
2868
+ activeTurnImageAttachments = [];
2279
2869
  store.update((current) => ({
2280
2870
  ...current,
2281
2871
  activeTurnInput: '',
@@ -2311,10 +2901,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2311
2901
  store.update((current) => ({
2312
2902
  ...current,
2313
2903
  busy: false,
2904
+ busyPausedAt: null,
2314
2905
  busySince: null,
2906
+ recoveryStatus: '',
2315
2907
  status: result.waitingForApproval ? 'Awaiting approval' : 'Ready',
2316
2908
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
2317
2909
  }));
2910
+ recoverUnacknowledgedMessages();
2318
2911
  await flushQueuedMessage();
2319
2912
  }
2320
2913
  catch (error) {
@@ -2325,15 +2918,29 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2325
2918
  return;
2326
2919
  }
2327
2920
  const cancelled = isTurnCancelledError(error);
2921
+ if (exitForAuthenticationError(error))
2922
+ return;
2923
+ dismissPendingApproval();
2924
+ dismissPendingSudoPassword();
2925
+ dismissPendingUserInput();
2926
+ const failedTurnAttachments = activeTurnImageAttachments;
2927
+ activeTurnImageAttachments = [];
2928
+ const quotaNotice = cancelled ? null : buildQuotaNotice(error);
2328
2929
  store.update((current) => ({
2329
2930
  ...current,
2330
2931
  activeTurnInput: '',
2331
2932
  activeTurnInputPreformatted: false,
2332
2933
  busy: false,
2934
+ busyPausedAt: null,
2333
2935
  busySince: null,
2334
2936
  commandLog: [],
2335
- imageAttachments: [],
2336
- status: cancelled ? 'Ready' : 'Turn failed',
2937
+ imageAttachments: failedTurnAttachments,
2938
+ recoveryStatus: '',
2939
+ status: cancelled
2940
+ ? 'Ready'
2941
+ : quotaNotice
2942
+ ? quotaNotice.title
2943
+ : 'Turn failed',
2337
2944
  thinkingTitle: '',
2338
2945
  thinkingNotes: [],
2339
2946
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
@@ -2349,14 +2956,28 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2349
2956
  title: 'System',
2350
2957
  },
2351
2958
  ]);
2352
- await saveActiveSession();
2959
+ if (!(await saveActiveSession()))
2960
+ return;
2961
+ }
2962
+ else if (quotaNotice) {
2963
+ appendStaticEntries([
2964
+ ...turnEntries,
2965
+ {
2966
+ body: quotaNotice.body,
2967
+ kind: 'system',
2968
+ title: quotaNotice.title,
2969
+ },
2970
+ ]);
2353
2971
  }
2354
2972
  else {
2355
2973
  appendStaticEntries(turnEntries);
2356
2974
  appendError(error.message);
2975
+ appendStaticEntries(buildTurnFailureHintEntries(cancelled));
2357
2976
  }
2358
2977
  await remountTui();
2359
- if (!cancelled && turnGeneration === activeTurnGeneration) {
2978
+ const autoSubmits = !cancelled && isTurnOutcomeKnown(error);
2979
+ recoverUnacknowledgedMessages(autoSubmits);
2980
+ if (autoSubmits && turnGeneration === activeTurnGeneration) {
2360
2981
  await flushQueuedMessage();
2361
2982
  }
2362
2983
  }
@@ -2366,24 +2987,60 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2366
2987
  }
2367
2988
  if (turnGeneration === activeTurnGeneration) {
2368
2989
  lastTurnStartedAt = null;
2990
+ activeServerTurnId = null;
2369
2991
  }
2992
+ recoverUnacknowledgedMessages();
2993
+ await drainDeferredJobMutations();
2994
+ }
2995
+ };
2996
+ session.onImageAnalysis = (activeImageCount) => {
2997
+ store.update((current) => ({
2998
+ ...current,
2999
+ analyzingImages: Math.max(0, activeImageCount),
3000
+ generatingImage: activeImageCount > 0 ? false : current.generatingImage,
3001
+ }));
3002
+ };
3003
+ session.onRecovery = (status) => {
3004
+ if (status.phase === 'restored' || status.phase === 'continued') {
3005
+ store.update((current) => ({ ...current, recoveryStatus: '' }));
3006
+ appendTurnAwareEntry(status.phase === 'restored'
3007
+ ? { body: 'Continued the same turn.', kind: 'system', title: 'Connection restored' }
3008
+ : {
3009
+ body: 'Continued from the steps that had finished.',
3010
+ kind: 'system',
3011
+ title: 'Server restarted',
3012
+ });
3013
+ return;
2370
3014
  }
3015
+ store.update((current) => ({
3016
+ ...current,
3017
+ recoveryStatus: status.phase === 'reconnected' ? '' : formatTurnRecoveryStatus(status),
3018
+ }));
3019
+ };
3020
+ session.onImageGeneration = (active) => {
3021
+ store.update((current) => ({
3022
+ ...current,
3023
+ generatingImage: Boolean(active),
3024
+ analyzingImages: active ? 0 : current.analyzingImages,
3025
+ }));
2371
3026
  };
2372
3027
  session.onStatus = (message) => {
2373
3028
  const panel = thinkingPanelFromStatus(message);
2374
3029
  store.update((current) => ({
2375
3030
  ...current,
2376
3031
  status: message,
2377
- thinkingTitle: panel?.title ?? current.thinkingTitle,
2378
- thinkingNotes: panel?.notes ?? current.thinkingNotes,
2379
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
3032
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2380
3033
  }));
3034
+ queueThinkingUpdate({
3035
+ title: panel ? panel.title : liveStatusPanelTitle(message),
3036
+ notes: panel ? panel.notes : [],
3037
+ });
2381
3038
  };
2382
3039
  session.onContextLog = (message) => {
2383
3040
  store.update((current) => ({
2384
3041
  ...current,
2385
3042
  contextStatus: message,
2386
- tokenUsage: formatClientTokenUsage(current.busySince == null ? null : Date.now() - current.busySince, latestUsageSummary),
3043
+ tokenUsage: formatClientTokenUsage(busyElapsedMs(current, Date.now()), latestUsageSummary),
2387
3044
  }));
2388
3045
  };
2389
3046
  session.onToolEvent = (event) => {
@@ -2398,31 +3055,27 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2398
3055
  if (isFileChangeTool(event.call.name)) {
2399
3056
  const entry = buildFileChangeEntry(event);
2400
3057
  store.appendWorkingTool(entry);
2401
- appendTurnAwareEntry(entry);
3058
+ const result = event.result;
3059
+ if (result?.ok === true || result?.skipped === true)
3060
+ appendTurnAwareEntry(entry);
2402
3061
  return;
2403
3062
  }
2404
3063
  store.appendWorkingTool(buildWorkingToolEntry(event));
2405
3064
  };
2406
- const jobDetailLines = (jobId) => {
2407
- const buffered = getJobBufferedOutput(jobId);
2408
- if (!buffered)
2409
- return [];
2410
- const lines = buffered.output
2411
- .replace(/\r\n/g, '\n')
2412
- .replace(/\r/g, '\n')
2413
- .split('\n')
2414
- .filter((line) => line.trim().length > 0);
2415
- const visible = lines.slice(-20);
2416
- return buffered.droppedChars > 0
2417
- ? [
2418
- `... (${buffered.droppedChars} chars of older output dropped) ...`,
2419
- ...visible,
2420
- ]
2421
- : visible;
2422
- };
2423
3065
  const syncBackgroundJobsState = () => {
3066
+ const expandedId = store.getState().jobsPickerExpandedId;
2424
3067
  const jobsForDisplay = listBackgroundJobs().map((job) => {
2425
- const preview = getJobOutputPreview(job.id, 3);
3068
+ const expanded = job.id === expandedId;
3069
+ const preview = getJobOutputPreview(job.id, expanded ? 20 : 3);
3070
+ const dropped = preview?.droppedChars ?? 0;
3071
+ const detailLines = !expanded
3072
+ ? []
3073
+ : dropped > 0
3074
+ ? [
3075
+ `... (${dropped} chars of older output dropped) ...`,
3076
+ ...(preview?.tailLines ?? []),
3077
+ ]
3078
+ : (preview?.tailLines ?? []);
2426
3079
  return {
2427
3080
  id: job.id,
2428
3081
  command: job.command,
@@ -2431,8 +3084,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2431
3084
  startedAt: job.startedAt,
2432
3085
  endedAt: job.endedAt,
2433
3086
  firstOutputLine: preview?.firstLine ?? '',
2434
- tailLines: preview?.tailLines ?? [],
2435
- detailLines: jobDetailLines(job.id),
3087
+ tailLines: (preview?.tailLines ?? []).slice(-3),
3088
+ detailLines,
2436
3089
  };
2437
3090
  });
2438
3091
  store.update((current) => ({
@@ -2449,69 +3102,74 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2449
3102
  appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
2450
3103
  }
2451
3104
  });
2452
- projectIndex.onStatus = session.onStatus;
2453
- projectIndex.onContextLog = session.onContextLog;
2454
3105
  session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
2455
- session.confirmCommand = async (command) => {
3106
+ session.requestUserInput = async (request, signal) => openUserInputPrompt(request.questions, signal);
3107
+ session.requestPermission = async (request) => {
3108
+ const deny = { kind: 'deny' };
2456
3109
  if (exiting)
2457
- return false;
3110
+ return deny;
2458
3111
  if (sessionAutoYes)
2459
- return true;
2460
- const choice = await openApprovalPrompt('Approve command?', command);
2461
- if (choice === 'a') {
2462
- setAgentMode('auto-accept');
2463
- syncShellStateFromSession();
3112
+ return { kind: 'once' };
3113
+ const decision = await openApprovalPrompt(request, session.turnAbortSignal);
3114
+ if (decision.kind === 'always-bucket') {
2464
3115
  appendTurnAwareEntry({
2465
- body: 'Auto-approve enabled for the rest of this session.',
3116
+ body: `Allowed for the rest of this session: ${bucketActionLabel(request.bucket)}.`,
2466
3117
  kind: 'system',
2467
3118
  title: 'Approvals',
2468
3119
  });
2469
- return true;
2470
3120
  }
2471
- return choice === 'y';
2472
- };
2473
- session.confirmPatch = async (filePath, patch) => {
2474
- if (exiting)
2475
- return false;
2476
- if (sessionAutoYes)
2477
- return true;
2478
- const choice = await openApprovalPrompt('Approve patch?', 'Review changes before applying.', {
2479
- diff: patch,
2480
- filePath,
2481
- });
2482
- if (choice === 'a') {
2483
- setAgentMode('auto-accept');
2484
- syncShellStateFromSession();
3121
+ else if (decision.kind === 'always-prefix') {
2485
3122
  appendTurnAwareEntry({
2486
- body: 'Auto-approve enabled for the rest of this session.',
3123
+ body: `Allowed for the rest of this session: commands starting with \`${decision.prefix}\`.`,
3124
+ kind: 'system',
3125
+ title: 'Approvals',
3126
+ });
3127
+ }
3128
+ else if (decision.kind === 'always-origin') {
3129
+ appendTurnAwareEntry({
3130
+ body: `Allowed for the rest of this session: browser actions on ${decision.origin}.`,
2487
3131
  kind: 'system',
2488
3132
  title: 'Approvals',
2489
3133
  });
2490
- return true;
2491
3134
  }
2492
- return choice === 'y';
3135
+ return decision;
2493
3136
  };
2494
3137
  const shellInputHandlers = {
3138
+ getApprovalScrollLimit: () => {
3139
+ const contentWidth = frameContentWidth(terminalCols);
3140
+ return approvalScrollLimit(store.getState().approvalPrompt, contentWidth, terminalRows);
3141
+ },
3142
+ getComposerInputWidth: () => composerInputWidth(terminalCols, store.getState()),
2495
3143
  getTranscriptScrollLimit: () => {
2496
- const contentWidth = Math.max(20, Math.floor(terminalCols * 0.95) - 2);
3144
+ const contentWidth = frameContentWidth(terminalCols);
2497
3145
  const blocks = store.getState().transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
2498
3146
  return blocks.reduce((total, block, index) => total + block.length + (index > 0 ? 1 : 0), 0);
2499
3147
  },
3148
+ getUserInputViewport: () => {
3149
+ const nowMs = Date.now();
3150
+ const state = store.getState();
3151
+ return userInputViewportForFrame(state, terminalCols, terminalRows, spinnerFrame, busyElapsedSeconds(state, nowMs), nowMs);
3152
+ },
2500
3153
  onCancelTurn: cancelActiveTurn,
2501
3154
  onCycleAgentMode: cycleAgentMode,
3155
+ onAgentModeChangeBlocked: reportAgentModeLocked,
2502
3156
  onCtrlC: handleCtrlC,
2503
3157
  onLinkCopy: handleLinkCopy,
2504
3158
  onLinkOpen: handleLinkOpen,
2505
3159
  onLiveFrameShapeChange: scheduleLiveFrameRemount,
2506
3160
  onRequestExit: requestExit,
2507
3161
  onResolveApproval: handleInlineApprovalChoice,
3162
+ onResolveUserInput: handleInlineUserInput,
2508
3163
  onResumeSession: handleInlineResumeSelection,
2509
3164
  onSelectionCopy: handleAppSelectionCopy,
2510
3165
  onSelectModel: handleInlineModelSelection,
3166
+ onOpenJobsPicker: openJobsPicker,
3167
+ onToggleNotifications: toggleNotifications,
2511
3168
  onJobsPickerOutput: handleJobsPickerOutput,
2512
3169
  onJobsPickerKill: handleJobsPickerKill,
2513
3170
  onSudoPasswordInput: handleSudoPasswordInput,
2514
3171
  onSubmit: handleSubmit,
3172
+ onFireQueuedMessage: fireQueuedMessage,
2515
3173
  };
2516
3174
  const unsubscribe = store.subscribe(() => {
2517
3175
  renderCurrentFrame();
@@ -2519,6 +3177,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2519
3177
  const spinnerTimer = setInterval(() => {
2520
3178
  spinnerFrame = (spinnerFrame + 1) % 6;
2521
3179
  if (store.getState().busy) {
3180
+ flushPendingThinking();
2522
3181
  renderCurrentFrame();
2523
3182
  }
2524
3183
  }, 120);
@@ -2549,6 +3208,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2549
3208
  return;
2550
3209
  }
2551
3210
  if (message.op === 'event') {
3211
+ if (message.kind === 'focus') {
3212
+ store.update((current) => current.terminalFocused === message.focused
3213
+ ? current
3214
+ : { ...current, terminalFocused: message.focused });
3215
+ renderCurrentFrame();
3216
+ return;
3217
+ }
2552
3218
  if (message.kind === 'resize') {
2553
3219
  terminalCols = message.cols;
2554
3220
  terminalRows = message.rows;
@@ -2574,6 +3240,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2574
3240
  killAllBackgroundJobs();
2575
3241
  setBackgroundJobSession(null);
2576
3242
  setScratchSession(null);
3243
+ setImageStoreSession(null);
2577
3244
  setTodoSession(null);
2578
3245
  setBackgroundJobUpdateHook(null);
2579
3246
  await bridge.close();
@@ -2581,4 +3248,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2581
3248
  setCommandOutputHook(null);
2582
3249
  }
2583
3250
  });
3251
+ if (fatalError)
3252
+ throw fatalError;
3253
+ return { signedOut };
2584
3254
  }