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

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',
@@ -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,6 @@
1
1
  import { createRatatuiBridge } from './tui/bridge.js';
2
2
  import { bucketActionLabel, } from '../permissions.js';
3
- import { approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, userInputViewportForFrame, } from './tui/build-frame.js';
3
+ import { AGENT_MODE_LOCKED_STATUS, approvalScrollLimit, buildTuiFrame, formatJobElapsed, formatTodoProgress, pickThinkingFallbackPhrase, renderTranscriptEntryLines, THINKING_FALLBACK_PHRASES, userInputViewportForFrame, } from './tui/build-frame.js';
4
4
  import { createTerminalTitleController } from './tui/terminal-title.js';
5
5
  import { captureTerminalWrites, releaseTerminalWrites, } from './tui/terminal-writes.js';
6
6
  export { getSlashCommandSuggestions } from './tui/build-frame.js';
@@ -16,6 +16,7 @@ import { cancelActiveCommand } from '../executor.js';
16
16
  import { isTurnFailureMarker } from '../turn-failure-marker.js';
17
17
  import { clearCliAuthConfig, logoutFromServer, } from '../api/auth.js';
18
18
  import { authenticationErrorMessage, isAuthenticationError, } from '../api/http.js';
19
+ import { buildQuotaNotice } from '../quota-notice.js';
19
20
  import { createUserInputPromptState, formatUserInputTranscript, } from './tui/user-input.js';
20
21
  import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
21
22
  import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
@@ -76,7 +77,6 @@ const TRANSCRIPT_DIFF_PREVIEW_LINES = 24;
76
77
  const WORKING_TOOL_PREVIEW_ITEMS = 4;
77
78
  const THINKING_NOTE_PREVIEW_ROWS = 3;
78
79
  const WORKING_TOOL_PREVIEW_ROWS = 3;
79
- const AGENT_MODE_LABEL_WIDTH = 16;
80
80
  export const REPORT_ISSUE_URL = 'https://thegit.ai/report';
81
81
  export function buildReportTranscriptEntries(openedUrl) {
82
82
  return openedUrl
@@ -1105,6 +1105,8 @@ function createInitialShellState(session, serverModels, debugUi) {
1105
1105
  jobsPickerOpen: false,
1106
1106
  modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
1107
1107
  modelPickerOpen: false,
1108
+ shortcutsOpen: false,
1109
+ transientStatus: null,
1108
1110
  projectRoot: session.rootDir,
1109
1111
  sessionId: session.sessionId,
1110
1112
  showSessionId: debugUi.showSessionId,
@@ -1532,15 +1534,17 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1532
1534
  void remountTui();
1533
1535
  }, 0);
1534
1536
  };
1535
- const showTransientStatus = (status) => {
1537
+ const showTransientStatus = (transientStatus) => {
1536
1538
  if (transientStatusTimer) {
1537
1539
  clearTimeout(transientStatusTimer);
1538
1540
  transientStatusTimer = null;
1539
1541
  }
1540
- store.update((current) => ({ ...current, status }));
1542
+ store.update((current) => ({ ...current, transientStatus }));
1541
1543
  transientStatusTimer = setTimeout(() => {
1542
1544
  transientStatusTimer = null;
1543
- store.update((current) => current.status === status ? { ...current, status: 'Ready' } : current);
1545
+ store.update((current) => current.transientStatus === transientStatus
1546
+ ? { ...current, transientStatus: null }
1547
+ : current);
1544
1548
  }, 2500);
1545
1549
  };
1546
1550
  const terminalTitle = createTerminalTitleController({
@@ -1806,6 +1810,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
1806
1810
  setAgentMode(nextAgentMode(session.agentMode));
1807
1811
  scheduleLiveFrameRemount();
1808
1812
  };
1813
+ const reportAgentModeLocked = () => {
1814
+ showTransientStatus(AGENT_MODE_LOCKED_STATUS);
1815
+ };
1809
1816
  const clearLiveFrameRemountTimer = () => {
1810
1817
  if (!liveFrameRemountTimer)
1811
1818
  return;
@@ -2899,6 +2906,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2899
2906
  return;
2900
2907
  const failedTurnAttachments = activeTurnImageAttachments;
2901
2908
  activeTurnImageAttachments = [];
2909
+ const quotaNotice = cancelled ? null : buildQuotaNotice(error);
2902
2910
  store.update((current) => ({
2903
2911
  ...current,
2904
2912
  activeTurnInput: '',
@@ -2908,7 +2916,11 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2908
2916
  busySince: null,
2909
2917
  commandLog: [],
2910
2918
  imageAttachments: failedTurnAttachments,
2911
- status: cancelled ? 'Ready' : 'Turn failed',
2919
+ status: cancelled
2920
+ ? 'Ready'
2921
+ : quotaNotice
2922
+ ? quotaNotice.title
2923
+ : 'Turn failed',
2912
2924
  thinkingTitle: '',
2913
2925
  thinkingNotes: [],
2914
2926
  tokenUsage: formatClientTokenUsage(Date.now() - turnStartedAt, latestUsageSummary),
@@ -2927,6 +2939,16 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
2927
2939
  if (!(await saveActiveSession()))
2928
2940
  return;
2929
2941
  }
2942
+ else if (quotaNotice) {
2943
+ appendStaticEntries([
2944
+ ...turnEntries,
2945
+ {
2946
+ body: quotaNotice.body,
2947
+ kind: 'system',
2948
+ title: quotaNotice.title,
2949
+ },
2950
+ ]);
2951
+ }
2930
2952
  else {
2931
2953
  appendStaticEntries(turnEntries);
2932
2954
  appendError(error.message);
@@ -3090,6 +3112,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
3090
3112
  },
3091
3113
  onCancelTurn: cancelActiveTurn,
3092
3114
  onCycleAgentMode: cycleAgentMode,
3115
+ onAgentModeChangeBlocked: reportAgentModeLocked,
3093
3116
  onCtrlC: handleCtrlC,
3094
3117
  onLinkCopy: handleLinkCopy,
3095
3118
  onLinkOpen: handleLinkOpen,
@@ -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,10 @@ 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_CYCLE_HINT = ' (shift+tab to cycle)';
32
36
  const OVERLAY_PANEL_MAX_WIDTH = 86;
33
37
  const OVERLAY_PANEL_MARGIN_LINES = 2;
34
38
  const OVERLAY_PANEL_MARGIN_MIN_ROWS = 30;
@@ -434,10 +438,14 @@ function tokenUsageLines(usage) {
434
438
  }
435
439
  return [line(...spans)];
436
440
  }
441
+ export const AGENT_MODE_LOCKED_STATUS = 'Mode locked while the agent is working';
437
442
  function footerTransientStatus(status) {
438
443
  const text = String(status ?? '').trim();
439
444
  if (!text)
440
445
  return null;
446
+ if (text === AGENT_MODE_LOCKED_STATUS) {
447
+ return { color: 'yellow', text, icon: '!' };
448
+ }
441
449
  if (text === 'Copied selection' || text === 'Copied link') {
442
450
  return { color: 'green', text, icon: '✓' };
443
451
  }
@@ -478,9 +486,39 @@ function todoIndicatorSpans(state) {
478
486
  }),
479
487
  ];
480
488
  }
481
- function composerFooterLines(state) {
489
+ function composerFooterRightHints(state) {
490
+ const cancelHint = state.busy && !state.queuedMessage && !state.userInputPrompt
491
+ ? FOOTER_CANCEL_HINT
492
+ : '';
493
+ const helpHint = shortcutsPanelAvailable(state) ? FOOTER_HELP_HINT : '';
494
+ const tiers = [];
495
+ if (cancelHint && helpHint)
496
+ tiers.push(`${cancelHint} · ${helpHint}`);
497
+ if (cancelHint)
498
+ tiers.push(cancelHint);
499
+ if (helpHint)
500
+ tiers.push(helpHint);
501
+ tiers.push('');
502
+ return tiers;
503
+ }
504
+ export function fitComposerFooterRow({ modeLabels, modeLabelFallback, cycleHint, rightHints, leadWidth, tailWidth, contentWidth, }) {
505
+ const candidates = rightHints.flatMap((rightHint) => (cycleHint ? [cycleHint, ''] : ['']).flatMap((cycle) => modeLabels.map((modeLabel) => ({ modeLabel, cycleHint: cycle, rightHint }))));
506
+ const hasTail = (rightHint) => Boolean(rightHint) || tailWidth > 0;
507
+ const rowWidth = (choice, gap) => leadWidth +
508
+ displayWidth(choice.modeLabel) +
509
+ displayWidth(choice.cycleHint) +
510
+ (hasTail(choice.rightHint) ? gap : 0) +
511
+ displayWidth(choice.rightHint) +
512
+ tailWidth;
513
+ const chosen = candidates.find((choice) => rowWidth(choice, FOOTER_HINT_MIN_GAP) <= contentWidth) ?? { modeLabel: modeLabelFallback, cycleHint: '', rightHint: '' };
514
+ const gap = hasTail(chosen.rightHint)
515
+ ? Math.max(FOOTER_HINT_MIN_GAP, contentWidth - rowWidth(chosen, 0))
516
+ : 0;
517
+ return { ...chosen, gap };
518
+ }
519
+ function composerFooterLines(state, contentWidth) {
482
520
  const visibleSessionId = formatPromptSessionIdLabel(state.showSessionId, state.sessionId);
483
- const transientStatus = footerTransientStatus(state.status);
521
+ const transientStatus = footerTransientStatus(state.transientStatus ?? null);
484
522
  const lines = [
485
523
  line(span('TheGitAI', { color: 'cyan', bold: true }), span(' • ', { color: 'gray', dim: true }), span(formatPromptDirectoryLabel(state.projectRoot), { color: 'cyan' }), ...(transientStatus
486
524
  ? [
@@ -498,11 +536,6 @@ function composerFooterLines(state) {
498
536
  ? [span(` ${visibleSessionId}`, { color: 'gray', dim: true })]
499
537
  : []), ...backgroundJobIndicatorSpans(state), ...todoIndicatorSpans(state)),
500
538
  plainLine(''),
501
- plainLine(formatModelLabel(state.currentModelId, state.serverModels), {
502
- color: 'cyan',
503
- dim: true,
504
- }),
505
- plainLine(''),
506
539
  ];
507
540
  if (state.exitConfirmUntil != null) {
508
541
  lines.push(plainLine(state.status || 'Press Ctrl+C again to quit.', {
@@ -511,34 +544,36 @@ function composerFooterLines(state) {
511
544
  }));
512
545
  return lines;
513
546
  }
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
547
  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));
548
+ lines.push(composerFooterModeLine(state, contentWidth, formatModelLabel(state.currentModelId, state.serverModels), tokenUsageText
549
+ ? displayWidth(tokenUsageText) + FOOTER_USAGE_SEPARATOR.length
550
+ : 0, tokenUsageText
551
+ ? [
552
+ span(FOOTER_USAGE_SEPARATOR, mutedStyle()),
553
+ span(tokenUsageText, { color: 'cyan' }),
554
+ ]
555
+ : []));
540
556
  return lines;
541
557
  }
558
+ function composerFooterModeLine(state, contentWidth, modelLabel, usageWidth, usageSpans) {
559
+ const lead = modelLabel ? `${modelLabel}${FOOTER_MODEL_SEPARATOR}` : '';
560
+ const row = fitComposerFooterRow({
561
+ modeLabels: agentModeLabelTiers(state.agentMode),
562
+ modeLabelFallback: agentModeShortLabel(state.agentMode),
563
+ cycleHint: state.busy ? '' : FOOTER_CYCLE_HINT,
564
+ rightHints: composerFooterRightHints(state),
565
+ leadWidth: displayWidth(lead),
566
+ tailWidth: usageWidth,
567
+ contentWidth,
568
+ });
569
+ const footerMuted = mutedStyle();
570
+ return line(...(lead ? [span(lead, { color: 'cyan', dim: true })] : []), span(row.modeLabel, {
571
+ color: agentModeColor(state.agentMode),
572
+ bold: state.agentMode === 'auto-accept',
573
+ }), ...(row.cycleHint ? [span(row.cycleHint, footerMuted)] : []), ...(row.gap ? [span(' '.repeat(row.gap), footerMuted)] : []), ...(row.rightHint ? [span(row.rightHint, footerMuted)] : []), ...usageSpans);
574
+ }
575
+ const FOOTER_USAGE_SEPARATOR = ' ';
576
+ const FOOTER_MODEL_SEPARATOR = ' ';
542
577
  export function formatJobElapsed(elapsedMs) {
543
578
  const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
544
579
  if (totalSeconds < 60)
@@ -895,6 +930,71 @@ function buildModelPickerPanel(options, selectedIndex, width, availableHeight) {
895
930
  body.push(modelPickerDivider(panelWidth), modelPickerPanelSideLine(plainLine(fitLine(hint, innerWidth), { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
896
931
  return [...margin, ...body, ...margin];
897
932
  }
933
+ const SHORTCUTS_PANEL_MAX_WIDTH = 96;
934
+ const SHORTCUTS_KEY_COLUMN_MIN = 10;
935
+ function shortcutsTopBorder(panelWidth) {
936
+ const fullTitle = ' TheGitAI - Keyboard & mouse ';
937
+ const compactTitle = ' Shortcuts ';
938
+ const title = panelWidth >= fullTitle.length + 4 ? fullTitle : compactTitle;
939
+ const available = Math.max(0, panelWidth - 2 - [...title].length);
940
+ const left = Math.floor(available / 2);
941
+ const right = available - left;
942
+ 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 }));
943
+ }
944
+ export function buildShortcutsPanel(groups, width, availableHeight) {
945
+ const panelWidth = Math.max(28, Math.min(width, SHORTCUTS_PANEL_MAX_WIDTH));
946
+ const innerWidth = Math.max(1, panelWidth - 4);
947
+ const keyWidth = Math.max(SHORTCUTS_KEY_COLUMN_MIN, ...groups.flatMap((group) => group.rows.map((row) => displayWidth(row.keys))));
948
+ const side = (row) => overlayPanelLine(row, innerWidth, MODEL_PICKER_BORDER_COLOR);
949
+ const groupLines = (group, spaced) => [
950
+ ...(spaced ? [side(plainLine(''))] : []),
951
+ side(plainLine(fitLine(group.title, innerWidth), {
952
+ color: MODEL_PICKER_ACCENT_COLOR,
953
+ bold: true,
954
+ })),
955
+ ...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' })))),
956
+ ];
957
+ const chromeRows = 4;
958
+ const budget = Math.max(1, availableHeight - chromeRows);
959
+ const spacedBody = groups.flatMap((group, index) => groupLines(group, index > 0));
960
+ const plainBody = groups.flatMap((group) => groupLines(group, false));
961
+ let body = spacedBody;
962
+ let hidden = 0;
963
+ if (spacedBody.length > budget) {
964
+ body = plainBody;
965
+ if (plainBody.length > budget) {
966
+ body = [];
967
+ hidden = groups.reduce((total, group) => total + group.rows.length, 0);
968
+ for (const group of groups) {
969
+ const whole = groupLines(group, false);
970
+ if (body.length + whole.length <= budget) {
971
+ body = [...body, ...whole];
972
+ hidden -= group.rows.length;
973
+ continue;
974
+ }
975
+ const room = budget - body.length - 1;
976
+ if (room >= 1) {
977
+ const kept = group.rows.slice(0, room);
978
+ body = [...body, ...groupLines({ ...group, rows: kept }, false)];
979
+ hidden -= kept.length;
980
+ }
981
+ break;
982
+ }
983
+ }
984
+ }
985
+ const hint = hidden > 0 ? `esc close • ${hidden} more in /help` : 'esc close';
986
+ return [
987
+ shortcutsTopBorder(panelWidth),
988
+ ...body,
989
+ plainLine(`├${'─'.repeat(panelWidth - 2)}┤`, {
990
+ color: MODEL_PICKER_BORDER_COLOR,
991
+ }),
992
+ side(plainLine(fitLine(hint, innerWidth), { color: 'gray' })),
993
+ plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, {
994
+ color: MODEL_PICKER_BORDER_COLOR,
995
+ }),
996
+ ];
997
+ }
898
998
  function commandPaletteTopBorder(panelWidth) {
899
999
  const prefix = '╭─ Commands ';
900
1000
  const suffix = '╮';
@@ -1134,6 +1234,9 @@ function buildOverlayLines(state, width, height, nowMs) {
1134
1234
  }));
1135
1235
  return buildOverlayPanel(lines, width, OVERLAY_BORDER_COLOR, height);
1136
1236
  }
1237
+ if (state.shortcutsOpen) {
1238
+ lines.push(...buildShortcutsPanel(buildShortcutGroups(), width, height));
1239
+ }
1137
1240
  if (state.modelPickerOpen) {
1138
1241
  const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
1139
1242
  lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width, height));
@@ -1212,7 +1315,7 @@ function countSectionLines(sections) {
1212
1315
  export function userInputViewportForFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
1213
1316
  const contentWidth = frameContentWidth(cols);
1214
1317
  const liveRows = buildLiveLines(state, contentWidth, spinnerFrame, elapsedSeconds, nowMs).length;
1215
- const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state).length);
1318
+ const overlayHeight = Math.max(0, rows - liveRows - composerFooterLines(state, contentWidth).length);
1216
1319
  return {
1217
1320
  width: approvalPanelInnerWidth(contentWidth),
1218
1321
  maxRows: overlayPanelContentBudget(overlayHeight),
@@ -1248,7 +1351,11 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1248
1351
  sections.push({ kind: 'live', lines: liveLines });
1249
1352
  }
1250
1353
  const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt || state.userInputPrompt);
1251
- if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
1354
+ if (!state.resumePickerOpen &&
1355
+ !state.modelPickerOpen &&
1356
+ !state.jobsPickerOpen &&
1357
+ !state.shortcutsOpen &&
1358
+ !overlayActive) {
1252
1359
  const composerLines = [];
1253
1360
  if (state.busy && state.status === 'Starting a new conversation...') {
1254
1361
  composerLines.push(line(span('Starting a new conversation…', { color: 'gray', dim: true })));
@@ -1268,18 +1375,18 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, n
1268
1375
  }
1269
1376
  sections.push({
1270
1377
  kind: 'composer',
1271
- lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
1378
+ lines: [...composerLines, plainLine(''), ...composerFooterLines(state, contentWidth)],
1272
1379
  });
1273
1380
  }
1274
1381
  if (state.userInputPrompt) {
1275
1382
  sections.push({
1276
1383
  kind: 'busyFooter',
1277
- lines: composerFooterLines(state),
1384
+ lines: composerFooterLines(state, contentWidth),
1278
1385
  });
1279
1386
  }
1280
1387
  const overlayHeight = state.userInputPrompt
1281
1388
  ? Math.max(0, rows - countSectionLines(sections))
1282
- : state.jobsPickerOpen
1389
+ : state.jobsPickerOpen || state.shortcutsOpen
1283
1390
  ? Math.max(Math.min(rows, JOBS_PICKER_MIN_ROWS), rows - countSectionLines(sections))
1284
1391
  : rows;
1285
1392
  const overlayLines = buildOverlayLines(state, contentWidth, overlayHeight, nowMs);
@@ -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,24 @@ 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.input === '?' && !key.ctrl && !key.meta && shortcutsPanelAvailable(state)) {
433
+ handlers.onLiveFrameShapeChange();
434
+ store.update((current) => ({ ...current, shortcutsOpen: true }));
435
+ return;
436
+ }
424
437
  if (key.escape) {
425
438
  if (state.busy) {
426
439
  if (state.queuedMessage) {
@@ -449,6 +462,10 @@ export function handleShellKeyEvent(store, handlers, event) {
449
462
  return;
450
463
  }
451
464
  if (key.tab && key.shift) {
465
+ if (state.busy) {
466
+ handlers.onAgentModeChangeBlocked?.();
467
+ return;
468
+ }
452
469
  handlers.onCycleAgentMode();
453
470
  return;
454
471
  }
@@ -0,0 +1,60 @@
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
+ ],
30
+ },
31
+ {
32
+ title: 'While the agent is working',
33
+ rows: [
34
+ { keys: 'Enter', description: 'Queue a message; Enter again sends it into the turn' },
35
+ { keys: '↑', description: 'Bring a queued message back to edit it' },
36
+ { keys: 'Esc', description: 'Discard the queued message, or cancel the turn' },
37
+ { keys: 'Ctrl+C', description: 'Cancel the turn' },
38
+ { keys: 'Ctrl+B', description: 'Background jobs' },
39
+ ],
40
+ },
41
+ {
42
+ title: 'Clipboard & images',
43
+ rows: [
44
+ { keys: imagePaste, description: 'Paste a screenshot into the message' },
45
+ { keys: textPaste, description: 'Paste text (your terminal’s own shortcut)' },
46
+ { keys: 'Right-click', description: 'Paste, or copy the link under the cursor' },
47
+ ],
48
+ },
49
+ {
50
+ title: 'Transcript',
51
+ rows: [
52
+ { keys: 'PageUp / PageDown', description: 'Scroll — the mouse wheel works too' },
53
+ { keys: 'Drag', description: 'Select and copy' },
54
+ { keys: 'Double-click', description: 'Copy a word' },
55
+ { keys: 'Triple-click', description: 'Copy a whole row' },
56
+ { keys: 'Left-click', description: 'Open a link' },
57
+ ],
58
+ },
59
+ ];
60
+ }
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.42",
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.42",
48
+ "@thegitai/tui-darwin-x64": "1.0.0-preview.42",
49
+ "@thegitai/tui-linux-arm64": "1.0.0-preview.42",
50
+ "@thegitai/tui-linux-x64": "1.0.0-preview.42",
51
+ "@thegitai/tui-win32-x64": "1.0.0-preview.42",
52
52
  "@vscode/ripgrep": "1.18.0"
53
53
  },
54
54
  "repository": {