@thegitai/cli 1.0.0-preview.41 → 1.0.0-preview.43

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -53,8 +53,8 @@ is required. Tabs used by the agent join a TheGitAI tab group in an existing
53
53
  window. The conversation stays in your terminal.
54
54
 
55
55
  Default mode asks before using a website and can remember that site for the
56
- session. Auto-Accept allows browser actions; Plan mode reads pages without
57
- clicking, typing, or navigating. Use Stop/Resume in the extension popup to
56
+ session. Bypass all permissions allows browser actions; Plan mode reads pages
57
+ without clicking, typing, or navigating. Use Stop/Resume in the extension popup to
58
58
  pause browser control, or Disable to disconnect it. Chrome's protected pages,
59
59
  including browser settings and extension management, cannot be controlled.
60
60
 
@@ -76,8 +76,9 @@ Multi-select questions toggle choices. The last question has an explicit
76
76
  the form; **Ctrl+C** cancels the whole turn.
77
77
 
78
78
  Answers return to the same turn, and completed question/answer records remain
79
- readable when the session is resumed. Default, Auto-Accept, and Plan modes all
80
- support the form; Auto-Accept does not choose answers for you.
79
+ readable when the session is resumed. Default, Bypass all permissions, and Plan
80
+ modes all support the form; Bypass all permissions does not choose answers for
81
+ you.
81
82
 
82
83
  ## Visible to-do list
83
84
 
@@ -145,8 +145,31 @@ export function buildPlanModeToolBlockedResult(toolName, action = 'Plan mode is
145
145
  }
146
146
  export function agentModeLabel(mode) {
147
147
  if (mode === 'auto-accept')
148
- return 'Auto-Accept';
148
+ return 'Bypass all permissions';
149
149
  if (mode === 'plan')
150
150
  return 'Plan · read-only';
151
+ return 'Default · asks to edit & run unless permitted';
152
+ }
153
+ export function agentModeLabelTiers(mode) {
154
+ if (mode === 'default') {
155
+ return [agentModeLabel(mode), 'Default · asks to edit & run'];
156
+ }
157
+ return [agentModeLabel(mode)];
158
+ }
159
+ export function agentModeShortLabel(mode) {
160
+ if (mode === 'auto-accept')
161
+ return 'Bypass';
162
+ if (mode === 'plan')
163
+ return 'Plan';
151
164
  return 'Default';
152
165
  }
166
+ export function agentModeColor(mode) {
167
+ if (mode === 'auto-accept')
168
+ return 'red';
169
+ if (mode === 'plan')
170
+ return 'yellow';
171
+ return 'cyan';
172
+ }
173
+ export function agentModeCycleSequence() {
174
+ return AGENT_MODES.map(agentModeShortLabel).join(' → ');
175
+ }
@@ -7,12 +7,14 @@ export class ServerApiError extends Error {
7
7
  status;
8
8
  traceId;
9
9
  code;
10
- constructor(message, status, traceId, code = '') {
10
+ details;
11
+ constructor(message, status, traceId, code = '', details = {}) {
11
12
  super(traceId ? `${message}\nTrace ID: ${traceId}` : message);
12
13
  this.name = 'ServerApiError';
13
14
  this.status = status;
14
15
  this.traceId = traceId;
15
16
  this.code = code;
17
+ this.details = details;
16
18
  }
17
19
  }
18
20
  export function createTraceId() {
@@ -187,6 +189,8 @@ export function gatewayFailureCategory(error) {
187
189
  return 'connection_error';
188
190
  if (!(error instanceof ServerApiError))
189
191
  return null;
192
+ if (isQuotaExceededError(error))
193
+ return 'quota_exceeded';
190
194
  return error.status in GATEWAY_STATUS_MESSAGES ? 'gateway_error' : null;
191
195
  }
192
196
  export async function readJsonResponse(response) {
@@ -208,6 +212,26 @@ export function failureMessage(data, status) {
208
212
  export function failureCode(data) {
209
213
  return typeof data?.error?.code === 'string' ? data.error.code : '';
210
214
  }
215
+ export function failureDetails(data) {
216
+ const error = data?.error;
217
+ if (!error || typeof error !== 'object')
218
+ return {};
219
+ return {
220
+ ...(typeof error.window === 'string' ? { window: error.window } : {}),
221
+ ...(typeof error.resetsAt === 'string' ? { resetsAt: error.resetsAt } : {}),
222
+ ...(typeof error.percentLeft === 'number'
223
+ ? { percentLeft: error.percentLeft }
224
+ : {}),
225
+ };
226
+ }
227
+ export const QUOTA_EXCEEDED_CODE = 'QUOTA_EXCEEDED';
228
+ export function isQuotaExceededError(error) {
229
+ if (!(error instanceof ServerApiError))
230
+ return false;
231
+ if (error.code === QUOTA_EXCEEDED_CODE)
232
+ return true;
233
+ return error.status === 429 && !error.code;
234
+ }
211
235
  export function isAuthenticationError(error) {
212
236
  return error instanceof ServerApiError && error.status === 401;
213
237
  }
@@ -218,7 +242,7 @@ export function authenticationErrorMessage(error) {
218
242
  }
219
243
  export async function readErrorResponse(response, traceId = response.headers.get(TRACE_ID_HEADER) ?? '') {
220
244
  const data = await readJsonResponse(response);
221
- return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data));
245
+ return new ServerApiError(failureMessage(data, response.status), response.status, traceId, failureCode(data), failureDetails(data));
222
246
  }
223
247
  export async function authorizedJson({ config, path, method = 'GET', body = null, headers = {}, fetchImpl = globalThis.fetch, timeoutMs = REQUEST_TIMEOUT_MS, }) {
224
248
  const trace = createTraceContext();
@@ -235,7 +259,7 @@ export async function authorizedJson({ config, path, method = 'GET', body = null
235
259
  });
236
260
  const data = await readJsonResponse(response);
237
261
  if (!response.ok) {
238
- throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data));
262
+ throw new ServerApiError(failureMessage(data, response.status), response.status, trace.traceId, failureCode(data), failureDetails(data));
239
263
  }
240
264
  return data;
241
265
  }
@@ -45,24 +45,29 @@ const HELP_MARKDOWN = [
45
45
  '',
46
46
  '## Options',
47
47
  '',
48
- '- `-y, --yes` — start in Auto-Accept mode',
48
+ '- `-y, --yes` — start in Bypass all permissions mode',
49
49
  '- `-h, --help` — show this help',
50
50
  '- `-v, --version` — print the version and exit',
51
51
  '',
52
52
  '## Modes',
53
53
  '',
54
- '- Default — asks before creating, editing or deleting files and before',
55
- ' running commands. Each answer can be remembered for the rest of the',
56
- ' session; see Safety & approvals.',
57
- '- Auto-Accept — approves shell commands and file edits for the session.',
54
+ '- Default · asks to edit & run unless permitted — asks before creating,',
55
+ ' editing or deleting files, before running commands, and before touching a',
56
+ ' website. Each answer can be remembered for the rest of the session, and',
57
+ ' once it is, Default stops asking for that action; see Safety & approvals.',
58
+ '- Bypass all permissions — approves every command, file edit, deletion and',
59
+ ' browser action for the rest of the session, without asking. Shown in red',
60
+ ' in the footer. Blocked commands stay blocked.',
58
61
  '- Plan — read-only; the agent can inspect, ask typed questions, and plan,',
59
62
  ' with file-reading shell commands only. It will not edit files or run tests,',
60
63
  ' builds, diagnostics, package managers, project code, or network probes.',
61
- '- In the TUI, Shift+Tab cycles modes, mouse drag copies transcript/footer text, left-click opens rendered links, right-click copies rendered links or pastes in the sticky composer/footer when no selection is active, double-click copies a word, and triple-click copies a row.',
64
+ '- In the TUI, press **?** on an empty composer for the keyboard-and-mouse panel. Shift+Tab cycles Default → Bypass → Plan (refused while the agent is working), mouse drag copies transcript/footer text, left-click opens rendered links, right-click copies rendered links or pastes in the sticky composer/footer when no selection is active, double-click copies a word, and triple-click copies a row.',
62
65
  '',
63
66
  '## Keys & clipboard',
64
67
  '',
65
- '- **Enter** sends • **Shift+Tab** cycles modes • **Esc** cancels the turn •',
68
+ '- **?** on an empty composer opens the keyboard-and-mouse panel.',
69
+ '- **Enter** sends • **Shift+Tab** cycles Default → Bypass → Plan (only',
70
+ ' between turns) • **Esc** cancels the turn •',
66
71
  ' **Ctrl+C** clears the composer or the queued message, and quits once there',
67
72
  ' is nothing left to clear. These are the same on macOS, Linux, and Windows.',
68
73
  '- **While the agent is working**, Enter queues your message and locks the',
@@ -72,6 +77,11 @@ const HELP_MARKDOWN = [
72
77
  ' back for editing and **Esc**/**Ctrl+C** discards it; both unlock the',
73
78
  ' composer for the next message. **Ctrl+V** pastes a screenshot onto the',
74
79
  ' queued message, and it is delivered into the running turn with the text.',
80
+ '- **Ctrl+G** turns desktop notifications on or off. One is raised when a turn',
81
+ ' ends and when a permission prompt or a question is waiting for you, but',
82
+ ' only while the terminal is in the background — if you are looking at the',
83
+ ' session you are told nothing. Coming back clears it. The choice is',
84
+ ' remembered between sessions.',
75
85
  `- **Paste** into the composer with your terminal's paste shortcut (\`${PASTE_SHORTCUT}\``,
76
86
  ' on this system) or by right-clicking the composer.',
77
87
  '- **Copy** from the transcript by dragging to select; double-click copies a',
@@ -96,6 +106,7 @@ const HELP_MARKDOWN = [
96
106
  ' is working; press it again or Esc to close without touching the turn',
97
107
  '- `/jobs output <id>` — print one job\'s full captured output',
98
108
  '- `/jobs kill <id>` — stop one background job',
109
+ '- `/notify` — turn desktop notifications on or off (same as Ctrl+G)',
99
110
  '- `/new` — start a new conversation; this session remains saved',
100
111
  '- `/logout` — sign out and quit',
101
112
  '- `/exit` — quit the session',
@@ -0,0 +1,39 @@
1
+ import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ import { getClientStateDir } from './client-state.js';
4
+ export const DEFAULT_PREFERENCES = {
5
+ notifications: true,
6
+ };
7
+ export function getPreferencesPath(env = process.env) {
8
+ return path.join(getClientStateDir(env), 'prefs.json');
9
+ }
10
+ export function readPreferences(env = process.env) {
11
+ const filePath = getPreferencesPath(env);
12
+ if (!existsSync(filePath))
13
+ return { ...DEFAULT_PREFERENCES };
14
+ try {
15
+ const parsed = JSON.parse(readFileSync(filePath, 'utf8'));
16
+ return {
17
+ notifications: parsed.notifications === false ? false : DEFAULT_PREFERENCES.notifications,
18
+ };
19
+ }
20
+ catch {
21
+ return { ...DEFAULT_PREFERENCES };
22
+ }
23
+ }
24
+ export function writePreferences(preferences, env = process.env) {
25
+ const filePath = getPreferencesPath(env);
26
+ mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
27
+ const tempPath = `${filePath}.${process.pid}.tmp`;
28
+ try {
29
+ writeFileSync(tempPath, `${JSON.stringify(preferences, null, 2)}\n`, {
30
+ encoding: 'utf8',
31
+ mode: 0o600,
32
+ });
33
+ renameSync(tempPath, filePath);
34
+ }
35
+ catch (error) {
36
+ rmSync(tempPath, { force: true });
37
+ throw error;
38
+ }
39
+ }
@@ -0,0 +1,35 @@
1
+ import { isQuotaExceededError, ServerApiError } from './api/http.js';
2
+ import { USAGE_WINDOWS, formatUtcMinute, usageWindowLabel } from './usage.js';
3
+ export function buildQuotaNotice(error) {
4
+ if (!(error instanceof ServerApiError) || !isQuotaExceededError(error)) {
5
+ return null;
6
+ }
7
+ const label = usageWindowLabel(String(error.details.window ?? ''));
8
+ const inSentence = label.toLowerCase();
9
+ const resetsAt = error.details.resetsAt ?? null;
10
+ const lines = [];
11
+ if (label) {
12
+ const reset = resetsAt ? formatUtcMinute(resetsAt) : '';
13
+ lines.push(reset && reset !== 'unknown'
14
+ ? `You've used your ${inSentence} limit. It resets at ${reset}.`
15
+ : `You've used your ${inSentence} limit.`);
16
+ }
17
+ else {
18
+ lines.push(firstMessageLine(error));
19
+ }
20
+ lines.push(label
21
+ ? `Run /usage to see your ${otherWindowNames(label)} windows.`
22
+ : 'Run /usage to see your remaining usage.');
23
+ return { title: 'Usage limit reached', body: lines.join('\n') };
24
+ }
25
+ function otherWindowNames(usedLabel) {
26
+ const names = USAGE_WINDOWS.map(([name]) => name)
27
+ .filter((name) => name !== usedLabel)
28
+ .map((name) => name.toLowerCase());
29
+ return names.length > 1
30
+ ? `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`
31
+ : (names[0] ?? '');
32
+ }
33
+ function firstMessageLine(error) {
34
+ return String(error.message ?? '').split('\n')[0]?.trim() || 'Usage limit reached.';
35
+ }
@@ -1,6 +1,10 @@
1
+ import { basename } from 'node:path';
1
2
  import { createRatatuiBridge } from './tui/bridge.js';
2
3
  import { bucketActionLabel, } from '../permissions.js';
3
- import { approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, userInputViewportForFrame, } from './tui/build-frame.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';
4
8
  import { createTerminalTitleController } from './tui/terminal-title.js';
5
9
  import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
6
10
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
@@ -16,6 +20,7 @@ import { cancelActiveCommand } from '../executor.js';
16
20
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
17
21
  import { clearCliAuthConfig, logoutFromServer, } from '../api/auth.js';
18
22
  import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
23
+ import { buildQuotaNotice } from '../quota-notice.js';
19
24
  import { createUserInputPromptState, formatUserInputTranscript, } from './tui/user-input.js';
20
25
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
21
26
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
@@ -76,7 +81,6 @@ const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
76
81
  const WORKING_TOOL_PREVIEW_ITEMS = 4;
77
82
  const THINKING_NOTE_PREVIEW_ROWS = 3;
78
83
  const WORKING_TOOL_PREVIEW_ROWS = 3;
79
- const AGENT_MODE_LABEL_WIDTH = 16;
80
84
  export const REPORT_ISSUE_URL = 'https://thegit.ai/report';
81
85
  export function buildReportTranscriptEntries(openedUrl) {
82
86
  return openedUrl
@@ -135,6 +139,10 @@ export const SLASH_COMMANDS = [
135
139
  command: '/jobs',
136
140
  description: 'Background jobs: pick to view output or kill',
137
141
  },
142
+ {
143
+ command: '/notify',
144
+ description: 'Toggle desktop notifications when a turn ends (Ctrl+G)',
145
+ },
138
146
  {
139
147
  command: '/new',
140
148
  description: 'start a new conversation; this session remains saved',
@@ -1100,11 +1108,15 @@ function createInitialShellState(session, serverModels, debugUi) {
1100
1108
  exiting: false,
1101
1109
  input: '',
1102
1110
  maxToolSteps: session.maxToolSteps,
1111
+ notificationsEnabled: readPreferences(session.env).notifications,
1112
+ terminalFocused: INITIAL_TERMINAL_FOCUS,
1103
1113
  jobsPickerExpandedId: null,
1104
1114
  jobsPickerIndex: 0,
1105
1115
  jobsPickerOpen: false,
1106
1116
  modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
1107
1117
  modelPickerOpen: false,
1118
+ shortcutsOpen: false,
1119
+ transientStatus: null,
1108
1120
  projectRoot: session.rootDir,
1109
1121
  sessionId: session.sessionId,
1110
1122
  showSessionId: debugUi.showSessionId,
@@ -1532,15 +1544,17 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1532
1544
  void remountTui();
1533
1545
  }, 0);
1534
1546
  };
1535
- const showTransientStatus = (status) => {
1547
+ const showTransientStatus = (transientStatus) => {
1536
1548
  if (transientStatusTimer) {
1537
1549
  clearTimeout(transientStatusTimer);
1538
1550
  transientStatusTimer = null;
1539
1551
  }
1540
- store.update((current) => ({ ...current, status }));
1552
+ store.update((current) => ({ ...current, transientStatus }));
1541
1553
  transientStatusTimer = setTimeout(() => {
1542
1554
  transientStatusTimer = null;
1543
- store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1555
+ store.update((current) => current.transientStatus === transientStatus
1556
+ ? { ...current, transientStatus: null }
1557
+ : current);
1544
1558
  }, 2500);
1545
1559
  };
1546
1560
  const terminalTitle = createTerminalTitleController({
@@ -1553,11 +1567,42 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1553
1567
  busy: state.busy,
1554
1568
  });
1555
1569
  };
1570
+ const notifier = backendFor();
1571
+ let notificationHandle = null;
1572
+ const projectLabel = basename(session.rootDir) || 'TheGitAI';
1573
+ const notifications = createNotificationQueue();
1574
+ const attention = createAttentionController({
1575
+ notify: (kind) => {
1576
+ notifications.run(async () => {
1577
+ notificationHandle = await notifier.post({
1578
+ title: projectLabel,
1579
+ body: kind === 'blocked'
1580
+ ? 'Waiting for your answer'
1581
+ : 'Finished \u2014 your turn',
1582
+ }, notificationHandle);
1583
+ });
1584
+ },
1585
+ withdraw: () => {
1586
+ notifications.run(async () => {
1587
+ notifier.withdraw(notificationHandle);
1588
+ notificationHandle = null;
1589
+ });
1590
+ },
1591
+ });
1592
+ const syncAttention = () => {
1593
+ const state = store.getState();
1594
+ attention.sync({
1595
+ awaitingAnswer: Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt),
1596
+ busy: state.busy,
1597
+ focused: state.terminalFocused,
1598
+ }, state.notificationsEnabled);
1599
+ };
1556
1600
  const renderCurrentFrame = () => {
1557
1601
  if (!tuiReady)
1558
1602
  return;
1559
1603
  const state = store.getState();
1560
1604
  syncTerminalTitle();
1605
+ syncAttention();
1561
1606
  const elapsedSeconds = busyElapsedSeconds(state, Date.now());
1562
1607
  bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
1563
1608
  };
@@ -1806,6 +1851,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1806
1851
  setAgentMode(nextAgentMode(session.agentMode));
1807
1852
  scheduleLiveFrameRemount();
1808
1853
  };
1854
+ const reportAgentModeLocked = () => {
1855
+ showTransientStatus(AGENT_MODE_LOCKED_STATUS);
1856
+ };
1809
1857
  const clearLiveFrameRemountTimer = () => {
1810
1858
  if (!liveFrameRemountTimer)
1811
1859
  return;
@@ -2100,6 +2148,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2100
2148
  title: 'Model',
2101
2149
  });
2102
2150
  };
2151
+ const toggleNotifications = () => {
2152
+ const enabled = !store.getState().notificationsEnabled;
2153
+ store.update((current) => ({ ...current, notificationsEnabled: enabled }));
2154
+ try {
2155
+ writePreferences({ ...readPreferences(session.env), notifications: enabled }, session.env);
2156
+ }
2157
+ catch {
2158
+ }
2159
+ showTransientStatus(enabled ? 'Notifications on' : 'Notifications off');
2160
+ };
2103
2161
  const openJobsPicker = () => {
2104
2162
  if (store.getState().jobsPickerOpen)
2105
2163
  return;
@@ -2506,8 +2564,19 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2506
2564
  openJobsPicker();
2507
2565
  return;
2508
2566
  }
2567
+ if (input === '/notify') {
2568
+ store.update((current) => ({
2569
+ ...current,
2570
+ cursor: 0,
2571
+ input: '',
2572
+ pastedChunks: [],
2573
+ }));
2574
+ toggleNotifications();
2575
+ return;
2576
+ }
2509
2577
  appendTurnAwareEntry({
2510
- body: "Slash commands can't be queued while a turn is running. Ctrl+B opens background jobs.",
2578
+ body: "Slash commands can't be queued while a turn is running. " +
2579
+ 'Ctrl+B opens background jobs, Ctrl+G toggles notifications.',
2511
2580
  kind: 'system',
2512
2581
  title: 'Queued',
2513
2582
  });
@@ -2592,6 +2661,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2592
2661
  appendStaticEntries(buildReportTranscriptEntries(opened));
2593
2662
  return;
2594
2663
  }
2664
+ if (input === '/notify') {
2665
+ toggleNotifications();
2666
+ return;
2667
+ }
2595
2668
  if (input === '/jobs' || input.startsWith('/jobs ')) {
2596
2669
  const jobsArgs = input.slice('/jobs'.length).trim();
2597
2670
  if (!jobsArgs) {
@@ -2899,6 +2972,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2899
2972
  return;
2900
2973
  const failedTurnAttachments = activeTurnImageAttachments;
2901
2974
  activeTurnImageAttachments = [];
2975
+ const quotaNotice = cancelled ? null : buildQuotaNotice(error);
2902
2976
  store.update((current) => ({
2903
2977
  ...current,
2904
2978
  activeTurnInput: '',
@@ -2908,7 +2982,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2908
2982
  busySince: null,
2909
2983
  commandLog: [],
2910
2984
  imageAttachments: failedTurnAttachments,
2911
- status: cancelled ? 'Ready' : 'Turn failed',
2985
+ status: cancelled
2986
+ ? 'Ready'
2987
+ : quotaNotice
2988
+ ? quotaNotice.title
2989
+ : 'Turn failed',
2912
2990
  thinkingTitle: '',
2913
2991
  thinkingNotes: [],
2914
2992
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
@@ -2927,6 +3005,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2927
3005
  if (!(await saveActiveSession()))
2928
3006
  return;
2929
3007
  }
3008
+ else if (quotaNotice) {
3009
+ appendStaticEntries([
3010
+ ...turnEntries,
3011
+ {
3012
+ body: quotaNotice.body,
3013
+ kind: 'system',
3014
+ title: quotaNotice.title,
3015
+ },
3016
+ ]);
3017
+ }
2930
3018
  else {
2931
3019
  appendStaticEntries(turnEntries);
2932
3020
  appendError(error.message);
@@ -3090,6 +3178,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
3090
3178
  },
3091
3179
  onCancelTurn: cancelActiveTurn,
3092
3180
  onCycleAgentMode: cycleAgentMode,
3181
+ onAgentModeChangeBlocked: reportAgentModeLocked,
3093
3182
  onCtrlC: handleCtrlC,
3094
3183
  onLinkCopy: handleLinkCopy,
3095
3184
  onLinkOpen: handleLinkOpen,
@@ -3101,6 +3190,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
3101
3190
  onSelectionCopy: handleAppSelectionCopy,
3102
3191
  onSelectModel: handleInlineModelSelection,
3103
3192
  onOpenJobsPicker: openJobsPicker,
3193
+ onToggleNotifications: toggleNotifications,
3104
3194
  onJobsPickerOutput: handleJobsPickerOutput,
3105
3195
  onJobsPickerKill: handleJobsPickerKill,
3106
3196
  onSudoPasswordInput: handleSudoPasswordInput,
@@ -3144,6 +3234,13 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
3144
3234
  return;
3145
3235
  }
3146
3236
  if (message.op === 'event') {
3237
+ if (message.kind === 'focus') {
3238
+ store.update((current) => current.terminalFocused === message.focused
3239
+ ? current
3240
+ : { ...current, terminalFocused: message.focused });
3241
+ renderCurrentFrame();
3242
+ return;
3243
+ }
3147
3244
  if (message.kind === 'resize') {
3148
3245
  terminalCols = message.cols;
3149
3246
  terminalRows = message.rows;
@@ -0,0 +1,25 @@
1
+ export const INITIAL_TERMINAL_FOCUS = true;
2
+ export function createAttentionController(options) {
3
+ let previous = null;
4
+ let outstanding = false;
5
+ return {
6
+ sync(state, enabled) {
7
+ const last = previous;
8
+ previous = { ...state };
9
+ if (last && !last.focused && state.focused && outstanding) {
10
+ outstanding = false;
11
+ options.withdraw();
12
+ }
13
+ if (!last || !enabled)
14
+ return;
15
+ if (state.focused)
16
+ return;
17
+ const turnFinished = last.busy && !state.busy;
18
+ const nowBlocked = !last.awaitingAnswer && state.awaitingAnswer;
19
+ if (nowBlocked || turnFinished) {
20
+ outstanding = true;
21
+ options.notify(nowBlocked ? 'blocked' : 'finished');
22
+ }
23
+ },
24
+ };
25
+ }
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createInterface } from 'node:readline';
3
- import { existsSync } from 'node:fs';
3
+ import { existsSync, statSync } from 'node:fs';
4
4
  import { createRequire } from 'node:module';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
@@ -51,6 +51,9 @@ function normalizeChildMessage(raw) {
51
51
  url: String(raw.url ?? ''),
52
52
  };
53
53
  }
54
+ if (raw.kind === 'focus') {
55
+ return { op: 'event', kind: 'focus', focused: raw.focused === true };
56
+ }
54
57
  if (raw.kind === 'contextMenu') {
55
58
  return { op: 'event', kind: 'contextMenu' };
56
59
  }
@@ -95,13 +98,18 @@ function normalizeChildMessage(raw) {
95
98
  pageDown: bool(raw.pageDown ?? raw.page_down),
96
99
  };
97
100
  }
101
+ function isRepoCheckout(moduleDir) {
102
+ return [
103
+ path.join(moduleDir, '../../../../thegitai-tui/Cargo.toml'),
104
+ path.join(moduleDir, '../../../../../thegitai-tui/Cargo.toml'),
105
+ ].some((candidate) => existsSync(candidate));
106
+ }
98
107
  export function resolveTuiBinaryPath() {
99
108
  const binaryName = process.platform === 'win32' ? 'thegitai-tui.exe' : 'thegitai-tui';
100
109
  const platformPackage = `@thegitai/tui-${process.platform}-${process.arch}`;
101
- try {
102
- return requireFromHere.resolve(`${platformPackage}/${binaryName}`);
103
- }
104
- catch {
110
+ const override = String(process.env.THEGITAI_TUI_BINARY ?? '').trim();
111
+ if (override && existsSync(override)) {
112
+ return override;
105
113
  }
106
114
  const moduleDir = path.dirname(fileURLToPath(import.meta.url));
107
115
  const devCandidates = [
@@ -109,6 +117,24 @@ export function resolveTuiBinaryPath() {
109
117
  path.join(moduleDir, '../../../../bin', binaryName),
110
118
  ];
111
119
  const devBinary = devCandidates.find((candidate) => existsSync(candidate));
120
+ let publishedBinary = null;
121
+ try {
122
+ publishedBinary = requireFromHere.resolve(`${platformPackage}/${binaryName}`);
123
+ }
124
+ catch {
125
+ }
126
+ if (publishedBinary && devBinary && isRepoCheckout(moduleDir)) {
127
+ try {
128
+ if (statSync(devBinary).mtimeMs > statSync(publishedBinary).mtimeMs) {
129
+ return devBinary;
130
+ }
131
+ }
132
+ catch {
133
+ }
134
+ }
135
+ if (publishedBinary) {
136
+ return publishedBinary;
137
+ }
112
138
  if (devBinary) {
113
139
  return devBinary;
114
140
  }
@@ -1,9 +1,10 @@
1
- import { agentModeLabel } from '../../agent-mode.js';
1
+ import { agentModeColor, agentModeLabelTiers, agentModeShortLabel, } from '../../agent-mode.js';
2
2
  import { singleLinePreview, truncate } from '../../utils.js';
3
3
  import { formatClientTokenUsage } from '../repl.js';
4
4
  import { renderFormattedBodyLines, renderPreformattedBodyLines, } from './markdown-render.js';
5
5
  import { displayWidth, line, padToWidth, plainLine, sliceToWidth, span, wrapText, } from './text.js';
6
6
  import { isDarkTerminalBackground, mutedColor, mutedStyle } from './terminal-theme.js';
7
+ import { buildShortcutGroups, shortcutsPanelAvailable, } from './shortcuts.js';
7
8
  import { buildUserInputOverlayLines, } from './user-input.js';
8
9
  import { composerPromptLabel, frameContentWidth, splitComposerInput, } from './composer-layout.js';
9
10
  const WORKING_CLOCK_ICON = '◷';
@@ -28,7 +29,20 @@ const APPROVAL_SCROLLBAR_THUMB = '█';
28
29
  const APPROVAL_SCROLLBAR_TRACK = '░';
29
30
  const THINKING_NOTE_PREVIEW_ROWS = 3;
30
31
  const COMPOSER_INPUT_MAX_ROWS = 6;
31
- const AGENT_MODE_LABEL_WIDTH = 16;
32
+ const FOOTER_HINT_MIN_GAP = 2;
33
+ const FOOTER_HELP_HINT = '? for shortcuts';
34
+ const FOOTER_CANCEL_HINT = 'esc cancels turn';
35
+ const FOOTER_NOTIFICATIONS_ON = 'Notifications ON';
36
+ const FOOTER_NOTIFICATIONS_OFF = 'Notifications OFF';
37
+ export function footerNotificationHint(status) {
38
+ const text = String(status ?? '').trim();
39
+ if (text === 'Notifications on')
40
+ return FOOTER_NOTIFICATIONS_ON;
41
+ if (text === 'Notifications off')
42
+ return FOOTER_NOTIFICATIONS_OFF;
43
+ return '';
44
+ }
45
+ const FOOTER_CYCLE_HINT = ' (shift+tab to cycle)';
32
46
  const OVERLAY_PANEL_MAX_WIDTH = 86;
33
47
  const OVERLAY_PANEL_MARGIN_LINES = 2;
34
48
  const OVERLAY_PANEL_MARGIN_MIN_ROWS = 30;
@@ -176,10 +190,12 @@ export function formatPromptSessionIdLabel(showSessionId, sessionId) {
176
190
  }
177
191
  const CLIENT_SLASH_COMMANDS = [
178
192
  { command: '/help', description: 'Show available chat commands' },
193
+ { command: '/about', description: 'Show version and platform info' },
179
194
  { command: '/usage', description: 'Show account usage percentage and reset times' },
180
195
  { command: '/model', description: 'Switch the active model' },
181
196
  { command: '/resume', description: 'Open the session picker to resume a previous session' },
182
197
  { command: '/jobs', description: 'Background jobs: pick to view output or kill' },
198
+ { command: '/notify', description: 'Toggle desktop notifications when a turn ends (Ctrl+G)' },
183
199
  { command: '/report', description: 'Report a bug privately on the TheGitAI website' },
184
200
  { command: '/new', description: 'start a new conversation; this session remains saved' },
185
201
  { command: '/logout', description: 'Sign out and quit' },
@@ -434,10 +450,14 @@ function tokenUsageLines(usage) {
434
450
  }
435
451
  return [line(...spans)];
436
452
  }
453
+ export const AGENT_MODE_LOCKED_STATUS = 'Mode locked while the agent is working';
437
454
  function footerTransientStatus(status) {
438
455
  const text = String(status ?? '').trim();
439
456
  if (!text)
440
457
  return null;
458
+ if (text === AGENT_MODE_LOCKED_STATUS) {
459
+ return { color: 'yellow', text, icon: '!' };
460
+ }
441
461
  if (text === 'Copied selection' || text === 'Copied link') {
442
462
  return { color: 'green', text, icon: '✓' };
443
463
  }
@@ -478,9 +498,47 @@ function todoIndicatorSpans(state) {
478
498
  }),
479
499
  ];
480
500
  }
481
- function composerFooterLines(state) {
501
+ function composerFooterRightHints(state) {
502
+ const cancelHint = state.busy && !state.queuedMessage && !state.userInputPrompt
503
+ ? FOOTER_CANCEL_HINT
504
+ : '';
505
+ const helpHint = shortcutsPanelAvailable(state) ? FOOTER_HELP_HINT : '';
506
+ const notifyHint = footerNotificationHint(state.transientStatus);
507
+ const join = (...parts) => parts.filter(Boolean).join(' · ');
508
+ const tiers = [];
509
+ if (notifyHint) {
510
+ tiers.push(join(cancelHint, notifyHint, helpHint));
511
+ tiers.push(join(notifyHint, helpHint));
512
+ tiers.push(join(cancelHint, notifyHint));
513
+ tiers.push(notifyHint);
514
+ }
515
+ if (cancelHint && helpHint)
516
+ tiers.push(`${cancelHint} · ${helpHint}`);
517
+ if (cancelHint)
518
+ tiers.push(cancelHint);
519
+ if (helpHint)
520
+ tiers.push(helpHint);
521
+ tiers.push('');
522
+ return tiers.filter((tier, index) => tiers.indexOf(tier) === index);
523
+ }
524
+ export function fitComposerFooterRow({ modeLabels, modeLabelFallback, cycleHint, rightHints, leadWidth, tailWidth, contentWidth, }) {
525
+ const candidates = rightHints.flatMap((rightHint) => (cycleHint ? [cycleHint, ''] : ['']).flatMap((cycle) => modeLabels.map((modeLabel) => ({ modeLabel, cycleHint: cycle, rightHint }))));
526
+ const hasTail = (rightHint) => Boolean(rightHint) || tailWidth > 0;
527
+ const rowWidth = (choice, gap) => leadWidth +
528
+ displayWidth(choice.modeLabel) +
529
+ displayWidth(choice.cycleHint) +
530
+ (hasTail(choice.rightHint) ? gap : 0) +
531
+ displayWidth(choice.rightHint) +
532
+ tailWidth;
533
+ const chosen = candidates.find((choice) => rowWidth(choice, FOOTER_HINT_MIN_GAP) <= contentWidth) ?? { modeLabel: modeLabelFallback, cycleHint: '', rightHint: '' };
534
+ const gap = hasTail(chosen.rightHint)
535
+ ? Math.max(FOOTER_HINT_MIN_GAP, contentWidth - rowWidth(chosen, 0))
536
+ : 0;
537
+ return { ...chosen, gap };
538
+ }
539
+ function composerFooterLines(state, contentWidth) {
482
540
  const visibleSessionId = formatPromptSessionIdLabel(state.showSessionId, state.sessionId);
483
- const transientStatus = footerTransientStatus(state.status);
541
+ const transientStatus = footerTransientStatus(state.transientStatus ?? null);
484
542
  const lines = [
485
543
  line(span('TheGitAI', { color: 'cyan', bold: true }), span(' • ', { color: 'gray', dim: true }), span(formatPromptDirectoryLabel(state.projectRoot), { color: 'cyan' }), ...(transientStatus
486
544
  ? [
@@ -498,11 +556,6 @@ function composerFooterLines(state) {
498
556
  ? [span(` ${visibleSessionId}`, { color: 'gray', dim: true })]
499
557
  : []), ...backgroundJobIndicatorSpans(state), ...todoIndicatorSpans(state)),
500
558
  plainLine(''),
501
- plainLine(formatModelLabel(state.currentModelId, state.serverModels), {
502
- color: 'cyan',
503
- dim: true,
504
- }),
505
- plainLine(''),
506
559
  ];
507
560
  if (state.exitConfirmUntil != null) {
508
561
  lines.push(plainLine(state.status || 'Press Ctrl+C again to quit.', {
@@ -511,34 +564,36 @@ function composerFooterLines(state) {
511
564
  }));
512
565
  return lines;
513
566
  }
514
- const busyHelperText = state.queuedMessage
515
- ? 'Enter sends it to the agent • ↑ edit • Esc / Ctrl+C discard'
516
- : state.input
517
- ? 'Enter queues • Esc cancels turn • Ctrl+C clears draft'
518
- : 'Enter queues • Esc / Ctrl+C cancel turn';
519
- const helperText = state.userInputPrompt
520
- ? 'Answering agent questions • Ctrl+C cancels turn'
521
- : state.busy
522
- ? busyHelperText
523
- : process.platform === 'win32'
524
- ? 'Enter sends • Shift+Tab mode • Alt+V image • Esc cancel turn • Ctrl+C clears / quits'
525
- : process.platform === 'darwin'
526
- ? 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits'
527
- : 'Enter sends • Shift+Tab mode • Ctrl+V image • Esc cancel turn • Ctrl+C clears / quits';
528
- const agentLabel = agentModeLabel(state.agentMode).padEnd(AGENT_MODE_LABEL_WIDTH);
529
567
  const tokenUsageText = state.tokenUsage || formatClientTokenUsage(null);
530
- const footerMuted = mutedStyle();
531
- const footerSpans = [
532
- span(helperText, footerMuted),
533
- span(' '.repeat(Math.max(1, 8)), footerMuted),
534
- span(agentLabel, {
535
- color: state.agentMode === 'plan' ? 'yellow' : 'cyan',
536
- }),
537
- span(tokenUsageText, { color: 'cyan' }),
538
- ];
539
- lines.push(line(...footerSpans));
568
+ lines.push(composerFooterModeLine(state, contentWidth, formatModelLabel(state.currentModelId, state.serverModels), tokenUsageText
569
+ ? displayWidth(tokenUsageText) + FOOTER_USAGE_SEPARATOR.length
570
+ : 0, tokenUsageText
571
+ ? [
572
+ span(FOOTER_USAGE_SEPARATOR, mutedStyle()),
573
+ span(tokenUsageText, { color: 'cyan' }),
574
+ ]
575
+ : []));
540
576
  return lines;
541
577
  }
578
+ function composerFooterModeLine(state, contentWidth, modelLabel, usageWidth, usageSpans) {
579
+ const lead = modelLabel ? `${modelLabel}${FOOTER_MODEL_SEPARATOR}` : '';
580
+ const row = fitComposerFooterRow({
581
+ modeLabels: agentModeLabelTiers(state.agentMode),
582
+ modeLabelFallback: agentModeShortLabel(state.agentMode),
583
+ cycleHint: state.busy ? '' : FOOTER_CYCLE_HINT,
584
+ rightHints: composerFooterRightHints(state),
585
+ leadWidth: displayWidth(lead),
586
+ tailWidth: usageWidth,
587
+ contentWidth,
588
+ });
589
+ const footerMuted = mutedStyle();
590
+ return line(...(lead ? [span(lead, { color: 'cyan', dim: true })] : []), span(row.modeLabel, {
591
+ color: agentModeColor(state.agentMode),
592
+ bold: state.agentMode === 'auto-accept',
593
+ }), ...(row.cycleHint ? [span(row.cycleHint, footerMuted)] : []), ...(row.gap ? [span(' '.repeat(row.gap), footerMuted)] : []), ...(row.rightHint ? [span(row.rightHint, footerMuted)] : []), ...usageSpans);
594
+ }
595
+ const FOOTER_USAGE_SEPARATOR = ' ';
596
+ const FOOTER_MODEL_SEPARATOR = ' ';
542
597
  export function formatJobElapsed(elapsedMs) {
543
598
  const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
544
599
  if (totalSeconds < 60)
@@ -895,6 +950,71 @@ function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
895
950
  body.push(modelPickerDivider(panelWidth), modelPickerPanelSideLine(plainLine(fitLine(hint, innerWidth), { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
896
951
  return [...margin, ...body, ...margin];
897
952
  }
953
+ const SHORTCUTS_PANEL_MAX_WIDTH = 96;
954
+ const SHORTCUTS_KEY_COLUMN_MIN = 10;
955
+ function shortcutsTopBorder(panelWidth) {
956
+ const fullTitle = ' TheGitAI - Keyboard & mouse ';
957
+ const compactTitle = ' Shortcuts ';
958
+ const title = panelWidth >= fullTitle.length + 4 ? fullTitle : compactTitle;
959
+ const available = Math.max(0, panelWidth - 2 - [...title].length);
960
+ const left = Math.floor(available / 2);
961
+ const right = available - left;
962
+ return line(span(`╭${'─'.repeat(left)}`, { color: MODEL_PICKER_BORDER_COLOR }), span(title, { color: MODEL_PICKER_ACCENT_COLOR, bold: true }), span(`${'─'.repeat(right)}╮`, { color: MODEL_PICKER_BORDER_COLOR }));
963
+ }
964
+ export function buildShortcutsPanel(groups, width, availableHeight) {
965
+ const panelWidth = Math.max(28, Math.min(width, SHORTCUTS_PANEL_MAX_WIDTH));
966
+ const innerWidth = Math.max(1, panelWidth - 4);
967
+ const keyWidth = Math.max(SHORTCUTS_KEY_COLUMN_MIN, ...groups.flatMap((group) => group.rows.map((row) => displayWidth(row.keys))));
968
+ const side = (row) => overlayPanelLine(row, innerWidth, MODEL_PICKER_BORDER_COLOR);
969
+ const groupLines = (group, spaced) => [
970
+ ...(spaced ? [side(plainLine(''))] : []),
971
+ side(plainLine(fitLine(group.title, innerWidth), {
972
+ color: MODEL_PICKER_ACCENT_COLOR,
973
+ bold: true,
974
+ })),
975
+ ...group.rows.map((row) => side(line(span(` ${row.keys}${' '.repeat(Math.max(0, keyWidth - displayWidth(row.keys)))} `, { bold: true }), span(fitLine(row.description, Math.max(1, innerWidth - keyWidth - 4)), { color: 'gray' })))),
976
+ ];
977
+ const chromeRows = 4;
978
+ const budget = Math.max(1, availableHeight - chromeRows);
979
+ const spacedBody = groups.flatMap((group, index) => groupLines(group, index > 0));
980
+ const plainBody = groups.flatMap((group) => groupLines(group, false));
981
+ let body = spacedBody;
982
+ let hidden = 0;
983
+ if (spacedBody.length > budget) {
984
+ body = plainBody;
985
+ if (plainBody.length > budget) {
986
+ body = [];
987
+ hidden = groups.reduce((total, group) => total + group.rows.length, 0);
988
+ for (const group of groups) {
989
+ const whole = groupLines(group, false);
990
+ if (body.length + whole.length <= budget) {
991
+ body = [...body, ...whole];
992
+ hidden -= group.rows.length;
993
+ continue;
994
+ }
995
+ const room = budget - body.length - 1;
996
+ if (room >= 1) {
997
+ const kept = group.rows.slice(0, room);
998
+ body = [...body, ...groupLines({ ...group, rows: kept }, false)];
999
+ hidden -= kept.length;
1000
+ }
1001
+ break;
1002
+ }
1003
+ }
1004
+ }
1005
+ const hint = hidden > 0 ? `esc close • ${hidden} more in /help` : 'esc close';
1006
+ return [
1007
+ shortcutsTopBorder(panelWidth),
1008
+ ...body,
1009
+ plainLine(`├${'─'.repeat(panelWidth - 2)}┤`, {
1010
+ color: MODEL_PICKER_BORDER_COLOR,
1011
+ }),
1012
+ side(plainLine(fitLine(hint, innerWidth), { color: 'gray' })),
1013
+ plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, {
1014
+ color: MODEL_PICKER_BORDER_COLOR,
1015
+ }),
1016
+ ];
1017
+ }
898
1018
  function commandPaletteTopBorder(panelWidth) {
899
1019
  const prefix = '╭─ Commands ';
900
1020
  const suffix = '╮';
@@ -1134,6 +1254,9 @@ function buildOverlayLines(state, width, height, nowMs) {
1134
1254
  }));
1135
1255
  return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
1136
1256
  }
1257
+ if (state.shortcutsOpen) {
1258
+ lines.push(...buildShortcutsPanel(buildShortcutGroups(), width, height));
1259
+ }
1137
1260
  if (state.modelPickerOpen) {
1138
1261
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
1139
1262
  lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
@@ -1212,7 +1335,7 @@ function countSectionLines(sections) {
1212
1335
  export function userInputViewportForFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1213
1336
  const contentWidth = frameContentWidth(cols);
1214
1337
  const liveRows = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs).length;
1215
- const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state).length);
1338
+ const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state, contentWidth).length);
1216
1339
  return {
1217
1340
  width: approvalPanelInnerWidth(contentWidth),
1218
1341
  maxRows: overlayPanelContentBudget(overlayHeight),
@@ -1248,7 +1371,11 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1248
1371
  sections.push({ kind: 'live', lines: liveLines });
1249
1372
  }
1250
1373
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt);
1251
- if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
1374
+ if (!state.resumePickerOpen &&
1375
+ !state.modelPickerOpen &&
1376
+ !state.jobsPickerOpen &&
1377
+ !state.shortcutsOpen &&
1378
+ !overlayActive) {
1252
1379
  const composerLines = [];
1253
1380
  if (state.busy && state.status === 'Starting a new conversation...') {
1254
1381
  composerLines.push(line(span('Starting a new conversation…', { color: 'gray', dim: true })));
@@ -1268,18 +1395,18 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1268
1395
  }
1269
1396
  sections.push({
1270
1397
  kind: 'composer',
1271
- lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
1398
+ lines: [...composerLines, plainLine(''), ...composerFooterLines(state, contentWidth)],
1272
1399
  });
1273
1400
  }
1274
1401
  if (state.userInputPrompt) {
1275
1402
  sections.push({
1276
1403
  kind: 'busyFooter',
1277
- lines: composerFooterLines(state),
1404
+ lines: composerFooterLines(state, contentWidth),
1278
1405
  });
1279
1406
  }
1280
1407
  const overlayHeight = state.userInputPrompt
1281
1408
  ? Math.max(0, rows - countSectionLines(sections))
1282
- : state.jobsPickerOpen
1409
+ : state.jobsPickerOpen || state.shortcutsOpen
1283
1410
  ? Math.max(Math.min(rows, JOBS_PICKER_MIN_ROWS), rows - countSectionLines(sections))
1284
1411
  : rows;
1285
1412
  const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
@@ -0,0 +1,118 @@
1
+ import { spawn } from 'node:child_process';
2
+ function run(command, args, { capture = false } = {}) {
3
+ return new Promise((resolve) => {
4
+ let child;
5
+ try {
6
+ child = spawn(command, args, {
7
+ stdio: ['ignore', capture ? 'pipe' : 'ignore', 'ignore'],
8
+ });
9
+ }
10
+ catch {
11
+ resolve('');
12
+ return;
13
+ }
14
+ let out = '';
15
+ child.stdout?.on('data', (chunk) => {
16
+ out += chunk.toString('utf8');
17
+ });
18
+ child.on('error', () => resolve(''));
19
+ child.on('close', () => resolve(out.trim()));
20
+ });
21
+ }
22
+ const linuxBackend = {
23
+ canWithdraw: true,
24
+ async post(request, previous) {
25
+ const args = [
26
+ '--print-id',
27
+ '--app-name=TheGitAI',
28
+ '--icon=utilities-terminal',
29
+ '--urgency=normal',
30
+ ];
31
+ if (previous)
32
+ args.push(`--replace-id=${previous}`);
33
+ args.push(request.title, request.body);
34
+ const id = await run('notify-send', args, { capture: true });
35
+ return /^\d+$/.test(id) ? id : null;
36
+ },
37
+ withdraw(handle) {
38
+ if (!handle)
39
+ return;
40
+ void run('gdbus', [
41
+ 'call',
42
+ '--session',
43
+ '--dest',
44
+ 'org.freedesktop.Notifications',
45
+ '--object-path',
46
+ '/org/freedesktop/Notifications',
47
+ '--method',
48
+ 'org.freedesktop.Notifications.CloseNotification',
49
+ handle,
50
+ ]);
51
+ },
52
+ };
53
+ function appleScriptString(value) {
54
+ return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
55
+ }
56
+ const macBackend = {
57
+ canWithdraw: false,
58
+ async post(request) {
59
+ await run('osascript', [
60
+ '-e',
61
+ `display notification "${appleScriptString(request.body)}" with title "${appleScriptString(request.title)}"`,
62
+ ]);
63
+ return null;
64
+ },
65
+ withdraw() {
66
+ },
67
+ };
68
+ function powerShellString(value) {
69
+ return value.replace(/'/g, "''");
70
+ }
71
+ const WINDOWS_AUMID = 'TheGitAI';
72
+ const WINDOWS_TAG = 'thegitai-attention';
73
+ const WINDOWS_GROUP = 'thegitai';
74
+ const windowsBackend = {
75
+ canWithdraw: true,
76
+ async post(request) {
77
+ const script = [
78
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null',
79
+ '[Windows.Data.Xml.Dom.XmlDocument, Windows.Data.Xml.Dom, ContentType=WindowsRuntime] > $null',
80
+ '$t = [Windows.UI.Notifications.ToastNotificationManager]::GetTemplateContent([Windows.UI.Notifications.ToastTemplateType]::ToastText02)',
81
+ '$n = $t.GetElementsByTagName("text")',
82
+ `$n.Item(0).AppendChild($t.CreateTextNode('${powerShellString(request.title)}')) > $null`,
83
+ `$n.Item(1).AppendChild($t.CreateTextNode('${powerShellString(request.body)}')) > $null`,
84
+ '$toast = [Windows.UI.Notifications.ToastNotification]::new($t)',
85
+ `$toast.Tag = '${WINDOWS_TAG}'`,
86
+ `$toast.Group = '${WINDOWS_GROUP}'`,
87
+ `[Windows.UI.Notifications.ToastNotificationManager]::CreateToastNotifier('${WINDOWS_AUMID}').Show($toast)`,
88
+ ].join('; ');
89
+ await run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script]);
90
+ return WINDOWS_TAG;
91
+ },
92
+ withdraw(handle) {
93
+ if (!handle)
94
+ return;
95
+ const script = [
96
+ '[Windows.UI.Notifications.ToastNotificationManager, Windows.UI.Notifications, ContentType=WindowsRuntime] > $null',
97
+ `[Windows.UI.Notifications.ToastNotificationManager]::History.Remove('${powerShellString(handle)}', '${WINDOWS_GROUP}', '${WINDOWS_AUMID}')`,
98
+ ].join('; ');
99
+ void run('powershell', ['-NoProfile', '-NonInteractive', '-Command', script]);
100
+ },
101
+ };
102
+ export function createNotificationQueue() {
103
+ let tail = Promise.resolve();
104
+ return {
105
+ idle: () => tail,
106
+ run(step) {
107
+ tail = tail.then(step).catch(() => {
108
+ });
109
+ },
110
+ };
111
+ }
112
+ export function backendFor(platform = process.platform) {
113
+ if (platform === 'darwin')
114
+ return macBackend;
115
+ if (platform === 'win32')
116
+ return windowsBackend;
117
+ return linuxBackend;
118
+ }
@@ -1,3 +1,4 @@
1
+ import { shortcutsPanelAvailable } from './shortcuts.js';
1
2
  import { moveComposerCursorVertically } from './composer-layout.js';
2
3
  import { readClipboardImage, readClipboardText } from '../../core/clipboard.js';
3
4
  import { MAX_IMAGES_PER_MESSAGE, MAX_TOTAL_IMAGE_BYTES_PER_MESSAGE, approximateBase64DecodedBytes, totalAttachmentBytes, } from '../../core/image-limits.js';
@@ -105,7 +106,7 @@ function pasteTextFromClipboard(store, handlers) {
105
106
  if (!text) {
106
107
  store.update((state) => ({
107
108
  ...state,
108
- status: 'Clipboard has no text to paste',
109
+ transientStatus: 'Clipboard has no text to paste',
109
110
  }));
110
111
  return;
111
112
  }
@@ -415,12 +416,28 @@ export function handleShellKeyEvent(store, handlers, event) {
415
416
  }
416
417
  return;
417
418
  }
419
+ if (state.shortcutsOpen) {
420
+ if (key.escape || key.returnKey || key.input === '?' || key.input === 'q') {
421
+ handlers.onLiveFrameShapeChange();
422
+ store.update((current) => ({ ...current, shortcutsOpen: false }));
423
+ }
424
+ return;
425
+ }
418
426
  if (key.ctrl && key.input === 'b' && !key.meta && !key.shift) {
419
427
  if ((state.backgroundJobs ?? []).length > 0) {
420
428
  handlers.onOpenJobsPicker();
421
429
  }
422
430
  return;
423
431
  }
432
+ if (key.ctrl && key.input === 'g' && !key.meta && !key.shift) {
433
+ handlers.onToggleNotifications?.();
434
+ return;
435
+ }
436
+ if (key.input === '?' && !key.ctrl && !key.meta && shortcutsPanelAvailable(state)) {
437
+ handlers.onLiveFrameShapeChange();
438
+ store.update((current) => ({ ...current, shortcutsOpen: true }));
439
+ return;
440
+ }
424
441
  if (key.escape) {
425
442
  if (state.busy) {
426
443
  if (state.queuedMessage) {
@@ -449,6 +466,10 @@ export function handleShellKeyEvent(store, handlers, event) {
449
466
  return;
450
467
  }
451
468
  if (key.tab && key.shift) {
469
+ if (state.busy) {
470
+ handlers.onAgentModeChangeBlocked?.();
471
+ return;
472
+ }
452
473
  handlers.onCycleAgentMode();
453
474
  return;
454
475
  }
@@ -0,0 +1,61 @@
1
+ import { agentModeCycleSequence } from '../../agent-mode.js';
2
+ export function shortcutsPanelAvailable(state) {
3
+ return (!state.exiting &&
4
+ !state.approvalPrompt &&
5
+ !state.sudoPrompt &&
6
+ !state.userInputPrompt &&
7
+ !state.queuedMessage &&
8
+ !state.input &&
9
+ (state.cursor ?? 0) === 0 &&
10
+ (state.pastedChunks?.length ?? 0) === 0 &&
11
+ state.promptHistoryCursor == null);
12
+ }
13
+ export function buildShortcutGroups(platform = process.platform) {
14
+ const imagePaste = platform === 'win32' ? 'Alt+V' : 'Ctrl+V';
15
+ const textPaste = platform === 'darwin' ? 'Cmd+V' : 'Ctrl+Shift+V';
16
+ return [
17
+ {
18
+ title: 'Composer',
19
+ rows: [
20
+ { keys: 'Enter', description: 'Send your message' },
21
+ {
22
+ keys: 'Shift+Tab',
23
+ description: `Cycle ${agentModeCycleSequence()} (between turns)`,
24
+ },
25
+ { keys: '/', description: 'Slash commands — /help lists them all' },
26
+ { keys: '↑ / ↓', description: 'Earlier prompts' },
27
+ { keys: 'Esc', description: 'Clear the composer' },
28
+ { keys: 'Ctrl+C', description: 'Clear the composer, or quit when empty' },
29
+ { keys: 'Ctrl+G', description: 'Desktop notifications — toggle on or off' },
30
+ ],
31
+ },
32
+ {
33
+ title: 'While the agent is working',
34
+ rows: [
35
+ { keys: 'Enter', description: 'Queue a message; Enter again sends it into the turn' },
36
+ { keys: '↑', description: 'Bring a queued message back to edit it' },
37
+ { keys: 'Esc', description: 'Discard the queued message, or cancel the turn' },
38
+ { keys: 'Ctrl+C', description: 'Cancel the turn' },
39
+ { keys: 'Ctrl+B', description: 'Background jobs' },
40
+ ],
41
+ },
42
+ {
43
+ title: 'Clipboard & images',
44
+ rows: [
45
+ { keys: imagePaste, description: 'Paste a screenshot into the message' },
46
+ { keys: textPaste, description: 'Paste text (your terminal’s own shortcut)' },
47
+ { keys: 'Right-click', description: 'Paste, or copy the link under the cursor' },
48
+ ],
49
+ },
50
+ {
51
+ title: 'Transcript',
52
+ rows: [
53
+ { keys: 'PageUp / PageDown', description: 'Scroll — the mouse wheel works too' },
54
+ { keys: 'Drag', description: 'Select and copy' },
55
+ { keys: 'Double-click', description: 'Copy a word' },
56
+ { keys: 'Triple-click', description: 'Copy a whole row' },
57
+ { keys: 'Left-click', description: 'Open a link' },
58
+ ],
59
+ },
60
+ ];
61
+ }
package/dist/src/usage.js CHANGED
@@ -1,9 +1,12 @@
1
- const USAGE_WINDOWS = [
1
+ export const USAGE_WINDOWS = [
2
2
  ['5-hour', '5-hourly'],
3
3
  ['Weekly', 'weekly'],
4
4
  ['Monthly', 'monthly'],
5
5
  ];
6
- function formatUtcMinute(value) {
6
+ export function usageWindowLabel(key) {
7
+ return USAGE_WINDOWS.find(([, id]) => id === key)?.[0] ?? '';
8
+ }
9
+ export function formatUtcMinute(value) {
7
10
  if (!value)
8
11
  return 'no active usage';
9
12
  const date = new Date(value);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thegitai/cli",
3
- "version": "1.0.0-preview.41",
3
+ "version": "1.0.0-preview.43",
4
4
  "description": "TheGitAI is an agentic AI coding tool for your terminal. It reads and searches your repository, writes and edits files, runs your tests, and verifies the change before handing it back.",
5
5
  "keywords": [
6
6
  "agentic-ai",
@@ -44,11 +44,11 @@
44
44
  "@lydell/node-pty-linux-x64": "1.1.0",
45
45
  "@lydell/node-pty-win32-arm64": "1.1.0",
46
46
  "@lydell/node-pty-win32-x64": "1.1.0",
47
- "@thegitai/tui-darwin-arm64": "1.0.0-preview.41",
48
- "@thegitai/tui-darwin-x64": "1.0.0-preview.41",
49
- "@thegitai/tui-linux-arm64": "1.0.0-preview.41",
50
- "@thegitai/tui-linux-x64": "1.0.0-preview.41",
51
- "@thegitai/tui-win32-x64": "1.0.0-preview.41",
47
+ "@thegitai/tui-darwin-arm64": "1.0.0-preview.43",
48
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.43",
49
+ "@thegitai/tui-linux-arm64": "1.0.0-preview.43",
50
+ "@thegitai/tui-linux-x64": "1.0.0-preview.43",
51
+ "@thegitai/tui-win32-x64": "1.0.0-preview.43",
52
52
  "@vscode/ripgrep": "1.18.0"
53
53
  },
54
54
  "repository": {