@thegitai/cli 1.0.0-beta.16 → 1.0.0-beta.18
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/dist/bin/ai.js +92 -11
- package/dist/src/agent-mode.js +4 -0
- package/dist/src/api/chat.js +43 -8
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/executor.js +4 -4
- package/dist/src/help-text.js +10 -0
- package/dist/src/tool-executor.js +132 -17
- package/dist/src/tools/index.js +4 -0
- package/dist/src/tools/run-command.js +81 -12
- package/dist/src/tools/shell-job-kill.js +48 -0
- package/dist/src/tools/shell-job-output.js +51 -0
- package/dist/src/ui/repl.js +239 -3
- package/dist/src/ui/tui/build-frame.js +99 -5
- package/dist/src/ui/tui/shell-input.js +33 -0
- package/package.json +5 -5
package/dist/src/ui/repl.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import { createRatatuiBridge } from './tui/bridge.js';
|
|
2
|
-
import { buildTuiFrame, renderTranscriptEntryLines } from './tui/build-frame.js';
|
|
2
|
+
import { buildTuiFrame, formatJobElapsed, renderTranscriptEntryLines, } from './tui/build-frame.js';
|
|
3
3
|
export { getSlashCommandSuggestions } from './tui/build-frame.js';
|
|
4
4
|
import { agentModeLabel, nextAgentMode, } from '../agent-mode.js';
|
|
5
5
|
import { chat, models } from '../api/index.js';
|
|
6
6
|
import { isTurnCancelledError } from '../api/chat.js';
|
|
7
|
+
import { getJobBufferedOutput, getJobOutputPreview, hasRunningBackgroundJobs, killAllBackgroundJobs, killBackgroundJob, listBackgroundJobs, setBackgroundJobSession, setBackgroundJobUpdateHook, } from '../background-jobs.js';
|
|
7
8
|
import { cancelActiveCommand } from '../executor.js';
|
|
8
9
|
import { setCommandOutputHook, withTuiMode } from '../runtime-mode.js';
|
|
10
|
+
import { collectBackgroundJobUiKillMutations, collectBackgroundJobUiOutputMutations, } from '../tool-executor.js';
|
|
9
11
|
import { clearConversation, } from '../session.js';
|
|
10
12
|
import { applySessionSnapshot, listSessionMetadata, loadSessionSnapshot, saveSessionState, } from '../session-store.js';
|
|
11
13
|
import { truncate } from '../utils.js';
|
|
@@ -102,6 +104,10 @@ export const SLASH_COMMANDS = [
|
|
|
102
104
|
command: '/resume',
|
|
103
105
|
description: 'Open the session picker',
|
|
104
106
|
},
|
|
107
|
+
{
|
|
108
|
+
command: '/jobs',
|
|
109
|
+
description: 'Background jobs: pick to view output or kill',
|
|
110
|
+
},
|
|
105
111
|
{
|
|
106
112
|
command: '/clear',
|
|
107
113
|
description: 'Clear conversation history',
|
|
@@ -643,12 +649,33 @@ function buildWorkingToolEntry(event) {
|
|
|
643
649
|
: '';
|
|
644
650
|
if (call.name === 'run_command') {
|
|
645
651
|
const command = String(call.args?.command ?? result?.command ?? '').trim();
|
|
652
|
+
if (result?.backgrounded === true && result?.jobId) {
|
|
653
|
+
return {
|
|
654
|
+
body: `$ ${truncate(command, 180)}\nBackground job ${String(result.jobId)} started.${error}`,
|
|
655
|
+
kind: result?.ok === true ? 'tool' : 'error',
|
|
656
|
+
preformatted: true,
|
|
657
|
+
title: 'Shell',
|
|
658
|
+
};
|
|
659
|
+
}
|
|
646
660
|
return {
|
|
647
661
|
body: `$ ${truncate(command, 180)}\n${formatRunCommandResultState(result)}${error}`,
|
|
648
662
|
kind: result?.ok === true ? 'tool' : 'error',
|
|
649
663
|
title: 'Shell',
|
|
650
664
|
};
|
|
651
665
|
}
|
|
666
|
+
if (call.name === 'shell_job_output' || call.name === 'shell_job_kill') {
|
|
667
|
+
const jobId = String(result?.jobId ?? call.args?.job_id ?? '').trim();
|
|
668
|
+
const status = String(result?.status ?? '').trim();
|
|
669
|
+
const exitCode = result?.exitCode;
|
|
670
|
+
const stateText = status
|
|
671
|
+
? `${status}${exitCode != null ? ` (code ${exitCode})` : ''}`
|
|
672
|
+
: formatToolResultState(result);
|
|
673
|
+
return {
|
|
674
|
+
body: `${jobId || '(unknown job)'} · ${stateText}${error}`,
|
|
675
|
+
kind: result?.ok === true ? 'tool' : 'error',
|
|
676
|
+
title: call.name === 'shell_job_kill' ? 'Kill job' : 'Job output',
|
|
677
|
+
};
|
|
678
|
+
}
|
|
652
679
|
if (call.name === 'run_node_script') {
|
|
653
680
|
return {
|
|
654
681
|
body: `node --input-type=module <script via stdin>\n${formatRunCommandResultState(result)}${error}`,
|
|
@@ -666,6 +693,32 @@ function buildWorkingToolEntry(event) {
|
|
|
666
693
|
title: 'Tool',
|
|
667
694
|
};
|
|
668
695
|
}
|
|
696
|
+
function buildBackgroundJobNoticeEntry(snapshot) {
|
|
697
|
+
const command = truncate(snapshot.command, 120);
|
|
698
|
+
const ran = formatJobElapsed((snapshot.endedAt ?? Date.now()) - snapshot.startedAt);
|
|
699
|
+
if (snapshot.status === 'error') {
|
|
700
|
+
return {
|
|
701
|
+
body: `✖ ${snapshot.id} (${command}) failed to start`,
|
|
702
|
+
kind: 'error',
|
|
703
|
+
preformatted: true,
|
|
704
|
+
title: 'Background job',
|
|
705
|
+
};
|
|
706
|
+
}
|
|
707
|
+
if (snapshot.status === 'killed') {
|
|
708
|
+
return {
|
|
709
|
+
body: `■ ${snapshot.id} (${command}) killed · ran ${ran}`,
|
|
710
|
+
kind: 'system',
|
|
711
|
+
preformatted: true,
|
|
712
|
+
title: 'Background job',
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
return {
|
|
716
|
+
body: `${snapshot.exitCode === 0 ? '✓' : '✖'} ${snapshot.id} (${command}) exited (code ${snapshot.exitCode ?? 1}) · ran ${ran}`,
|
|
717
|
+
kind: snapshot.exitCode === 0 ? 'system' : 'error',
|
|
718
|
+
preformatted: true,
|
|
719
|
+
title: 'Background job',
|
|
720
|
+
};
|
|
721
|
+
}
|
|
669
722
|
function findPendingToolCallByName(pendingCalls, name) {
|
|
670
723
|
for (const [id, call] of pendingCalls) {
|
|
671
724
|
if (call.name !== name)
|
|
@@ -868,6 +921,7 @@ function createInitialShellState(session, serverModels, debugUi) {
|
|
|
868
921
|
approvalCursor: getDefaultApprovalCursor(),
|
|
869
922
|
approvalPrompt: null,
|
|
870
923
|
autoYes: session.autoYes,
|
|
924
|
+
backgroundJobs: [],
|
|
871
925
|
busy: false,
|
|
872
926
|
busySince: null,
|
|
873
927
|
clockNow: Date.now(),
|
|
@@ -879,6 +933,9 @@ function createInitialShellState(session, serverModels, debugUi) {
|
|
|
879
933
|
exiting: false,
|
|
880
934
|
input: '',
|
|
881
935
|
maxToolSteps: session.maxToolSteps,
|
|
936
|
+
jobsPickerExpandedId: null,
|
|
937
|
+
jobsPickerIndex: 0,
|
|
938
|
+
jobsPickerOpen: false,
|
|
882
939
|
modelPickerIndex: getDefaultModelPickerIndex(session.modelId, serverModels.models),
|
|
883
940
|
modelPickerOpen: false,
|
|
884
941
|
projectRoot: session.rootDir,
|
|
@@ -1183,6 +1240,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1183
1240
|
throw new Error('Client TUI requires an interactive terminal.');
|
|
1184
1241
|
}
|
|
1185
1242
|
await withTuiMode(async () => {
|
|
1243
|
+
setBackgroundJobSession(session.sessionId);
|
|
1186
1244
|
const store = createShellStore(createInitialShellState(session, serverModels, debugUi));
|
|
1187
1245
|
store.replaceTranscript(createSessionTranscript(session));
|
|
1188
1246
|
let currentServerModels = serverModels;
|
|
@@ -1240,7 +1298,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1240
1298
|
const elapsedSeconds = state.busySince
|
|
1241
1299
|
? Math.max(0, Math.floor((Date.now() - state.busySince) / 1000))
|
|
1242
1300
|
: 0;
|
|
1243
|
-
bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds));
|
|
1301
|
+
bridge.render(buildTuiFrame(state, terminalCols, terminalRows, spinnerFrame, elapsedSeconds, Date.now()));
|
|
1244
1302
|
};
|
|
1245
1303
|
const remountTui = async () => {
|
|
1246
1304
|
if (remountPromise) {
|
|
@@ -1522,6 +1580,7 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1522
1580
|
dismissPendingApproval();
|
|
1523
1581
|
dismissPendingSudoPassword();
|
|
1524
1582
|
exiting = true;
|
|
1583
|
+
killAllBackgroundJobs();
|
|
1525
1584
|
store.update((current) => ({
|
|
1526
1585
|
...current,
|
|
1527
1586
|
exiting: true,
|
|
@@ -1676,6 +1735,87 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1676
1735
|
title: 'Model',
|
|
1677
1736
|
});
|
|
1678
1737
|
};
|
|
1738
|
+
const openJobsPicker = () => {
|
|
1739
|
+
syncBackgroundJobsState();
|
|
1740
|
+
store.update((current) => ({
|
|
1741
|
+
...current,
|
|
1742
|
+
commandCursor: 0,
|
|
1743
|
+
cursor: 0,
|
|
1744
|
+
input: '',
|
|
1745
|
+
jobsPickerExpandedId: null,
|
|
1746
|
+
jobsPickerIndex: 0,
|
|
1747
|
+
jobsPickerOpen: true,
|
|
1748
|
+
status: 'Background jobs',
|
|
1749
|
+
}));
|
|
1750
|
+
};
|
|
1751
|
+
const appendJobOutputEntry = async (jobId) => {
|
|
1752
|
+
const id = jobId.trim();
|
|
1753
|
+
await collectBackgroundJobUiOutputMutations({
|
|
1754
|
+
session,
|
|
1755
|
+
projectIndex,
|
|
1756
|
+
jobId: id,
|
|
1757
|
+
});
|
|
1758
|
+
const job = listBackgroundJobs().find((candidate) => candidate.id === id);
|
|
1759
|
+
const buffered = getJobBufferedOutput(id);
|
|
1760
|
+
if (!job || !buffered) {
|
|
1761
|
+
appendError(`Unknown background job id: ${id}`);
|
|
1762
|
+
return;
|
|
1763
|
+
}
|
|
1764
|
+
const dropped = buffered.droppedChars > 0
|
|
1765
|
+
? `... (${buffered.droppedChars} chars of older output dropped) ...\n`
|
|
1766
|
+
: '';
|
|
1767
|
+
appendStaticEntry({
|
|
1768
|
+
body: `${dropped}${buffered.output || '(no output captured)'}`,
|
|
1769
|
+
kind: 'system',
|
|
1770
|
+
preformatted: true,
|
|
1771
|
+
title: `${job.id} output — ${truncate(job.command, 80)}`,
|
|
1772
|
+
});
|
|
1773
|
+
};
|
|
1774
|
+
const selectedJobsPickerId = () => {
|
|
1775
|
+
const current = store.getState();
|
|
1776
|
+
if (!current.backgroundJobs.length)
|
|
1777
|
+
return null;
|
|
1778
|
+
const index = Math.min(Math.max(current.jobsPickerIndex, 0), current.backgroundJobs.length - 1);
|
|
1779
|
+
return current.backgroundJobs[index]?.id ?? null;
|
|
1780
|
+
};
|
|
1781
|
+
const handleJobsPickerOutput = async () => {
|
|
1782
|
+
const jobId = selectedJobsPickerId();
|
|
1783
|
+
if (!jobId)
|
|
1784
|
+
return;
|
|
1785
|
+
await collectBackgroundJobUiOutputMutations({
|
|
1786
|
+
session,
|
|
1787
|
+
projectIndex,
|
|
1788
|
+
jobId,
|
|
1789
|
+
});
|
|
1790
|
+
syncBackgroundJobsState();
|
|
1791
|
+
store.update((current) => ({
|
|
1792
|
+
...current,
|
|
1793
|
+
jobsPickerExpandedId: current.jobsPickerExpandedId === jobId ? null : jobId,
|
|
1794
|
+
}));
|
|
1795
|
+
};
|
|
1796
|
+
const handleJobsPickerKill = async () => {
|
|
1797
|
+
const jobId = selectedJobsPickerId();
|
|
1798
|
+
if (!jobId)
|
|
1799
|
+
return;
|
|
1800
|
+
const killed = await killBackgroundJob(jobId);
|
|
1801
|
+
await collectBackgroundJobUiKillMutations({
|
|
1802
|
+
session,
|
|
1803
|
+
projectIndex,
|
|
1804
|
+
jobId,
|
|
1805
|
+
result: killed,
|
|
1806
|
+
});
|
|
1807
|
+
syncBackgroundJobsState();
|
|
1808
|
+
if (!killed.ok) {
|
|
1809
|
+
appendError(killed.error ?? 'Background job kill failed.');
|
|
1810
|
+
}
|
|
1811
|
+
else if (killed.alreadyFinished) {
|
|
1812
|
+
appendStaticEntry({
|
|
1813
|
+
body: `${jobId} had already finished.`,
|
|
1814
|
+
kind: 'system',
|
|
1815
|
+
title: 'Background jobs',
|
|
1816
|
+
});
|
|
1817
|
+
}
|
|
1818
|
+
};
|
|
1679
1819
|
const handleInlineModelSelection = async () => {
|
|
1680
1820
|
const current = store.getState();
|
|
1681
1821
|
const options = buildModelPickerOptions(current.currentModelId, current.serverModels);
|
|
@@ -1762,6 +1902,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1762
1902
|
try {
|
|
1763
1903
|
const snapshot = await loadInteractiveSession(selected.id);
|
|
1764
1904
|
applySessionSnapshot(session, snapshot);
|
|
1905
|
+
setBackgroundJobSession(session.sessionId);
|
|
1906
|
+
syncBackgroundJobsState();
|
|
1765
1907
|
await saveActiveSession();
|
|
1766
1908
|
syncShellStateFromSession();
|
|
1767
1909
|
store.update((next) => ({
|
|
@@ -1883,6 +2025,46 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
1883
2025
|
});
|
|
1884
2026
|
return;
|
|
1885
2027
|
}
|
|
2028
|
+
if (input === '/jobs' || input.startsWith('/jobs ')) {
|
|
2029
|
+
const jobsArgs = input.slice('/jobs'.length).trim();
|
|
2030
|
+
if (!jobsArgs) {
|
|
2031
|
+
openJobsPicker();
|
|
2032
|
+
return;
|
|
2033
|
+
}
|
|
2034
|
+
const killMatch = jobsArgs.match(/^kill\s+(\S+)$/);
|
|
2035
|
+
if (killMatch) {
|
|
2036
|
+
const jobId = killMatch[1];
|
|
2037
|
+
const killed = await killBackgroundJob(jobId);
|
|
2038
|
+
await collectBackgroundJobUiKillMutations({
|
|
2039
|
+
session,
|
|
2040
|
+
projectIndex,
|
|
2041
|
+
jobId,
|
|
2042
|
+
result: killed,
|
|
2043
|
+
});
|
|
2044
|
+
if (!killed.ok) {
|
|
2045
|
+
appendError(killed.error ?? 'Background job kill failed.');
|
|
2046
|
+
}
|
|
2047
|
+
else if (killed.alreadyFinished) {
|
|
2048
|
+
appendStaticEntry({
|
|
2049
|
+
body: `${jobId} had already finished.`,
|
|
2050
|
+
kind: 'system',
|
|
2051
|
+
title: 'Background jobs',
|
|
2052
|
+
});
|
|
2053
|
+
}
|
|
2054
|
+
return;
|
|
2055
|
+
}
|
|
2056
|
+
const outputMatch = jobsArgs.match(/^output\s+(\S+)$/);
|
|
2057
|
+
if (outputMatch) {
|
|
2058
|
+
await appendJobOutputEntry(outputMatch[1]);
|
|
2059
|
+
return;
|
|
2060
|
+
}
|
|
2061
|
+
appendStaticEntry({
|
|
2062
|
+
body: 'Usage: /jobs — open the jobs picker · /jobs output <id> — full output · /jobs kill <id> — kill one',
|
|
2063
|
+
kind: 'system',
|
|
2064
|
+
title: 'Background jobs',
|
|
2065
|
+
});
|
|
2066
|
+
return;
|
|
2067
|
+
}
|
|
1886
2068
|
if (input === '/usage') {
|
|
1887
2069
|
store.update((current) => ({
|
|
1888
2070
|
...current,
|
|
@@ -2121,6 +2303,52 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2121
2303
|
}
|
|
2122
2304
|
store.appendWorkingTool(buildWorkingToolEntry(event));
|
|
2123
2305
|
};
|
|
2306
|
+
const jobDetailLines = (jobId) => {
|
|
2307
|
+
const buffered = getJobBufferedOutput(jobId);
|
|
2308
|
+
if (!buffered)
|
|
2309
|
+
return [];
|
|
2310
|
+
const lines = buffered.output
|
|
2311
|
+
.replace(/\r\n/g, '\n')
|
|
2312
|
+
.replace(/\r/g, '\n')
|
|
2313
|
+
.split('\n')
|
|
2314
|
+
.filter((line) => line.trim().length > 0);
|
|
2315
|
+
const visible = lines.slice(-20);
|
|
2316
|
+
return buffered.droppedChars > 0
|
|
2317
|
+
? [
|
|
2318
|
+
`... (${buffered.droppedChars} chars of older output dropped) ...`,
|
|
2319
|
+
...visible,
|
|
2320
|
+
]
|
|
2321
|
+
: visible;
|
|
2322
|
+
};
|
|
2323
|
+
const syncBackgroundJobsState = () => {
|
|
2324
|
+
const jobsForDisplay = listBackgroundJobs().map((job) => {
|
|
2325
|
+
const preview = getJobOutputPreview(job.id, 3);
|
|
2326
|
+
return {
|
|
2327
|
+
id: job.id,
|
|
2328
|
+
command: job.command,
|
|
2329
|
+
status: job.status,
|
|
2330
|
+
exitCode: job.exitCode,
|
|
2331
|
+
startedAt: job.startedAt,
|
|
2332
|
+
endedAt: job.endedAt,
|
|
2333
|
+
firstOutputLine: preview?.firstLine ?? '',
|
|
2334
|
+
tailLines: preview?.tailLines ?? [],
|
|
2335
|
+
detailLines: jobDetailLines(job.id),
|
|
2336
|
+
};
|
|
2337
|
+
});
|
|
2338
|
+
store.update((current) => ({
|
|
2339
|
+
...current,
|
|
2340
|
+
backgroundJobs: jobsForDisplay,
|
|
2341
|
+
jobsPickerExpandedId: jobsForDisplay.some((job) => job.id === current.jobsPickerExpandedId)
|
|
2342
|
+
? current.jobsPickerExpandedId
|
|
2343
|
+
: null,
|
|
2344
|
+
}));
|
|
2345
|
+
};
|
|
2346
|
+
setBackgroundJobUpdateHook((snapshot) => {
|
|
2347
|
+
syncBackgroundJobsState();
|
|
2348
|
+
if (snapshot.status !== 'running') {
|
|
2349
|
+
appendTurnAwareEntry(buildBackgroundJobNoticeEntry(snapshot));
|
|
2350
|
+
}
|
|
2351
|
+
});
|
|
2124
2352
|
projectIndex.onStatus = session.onStatus;
|
|
2125
2353
|
projectIndex.onContextLog = session.onContextLog;
|
|
2126
2354
|
session.requestSudoPassword = async ({ command, prompt, signal }) => openSudoPasswordPrompt(command, prompt, signal);
|
|
@@ -2180,6 +2408,8 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2180
2408
|
onResumeSession: handleInlineResumeSelection,
|
|
2181
2409
|
onSelectionCopy: handleAppSelectionCopy,
|
|
2182
2410
|
onSelectModel: handleInlineModelSelection,
|
|
2411
|
+
onJobsPickerOutput: handleJobsPickerOutput,
|
|
2412
|
+
onJobsPickerKill: handleJobsPickerKill,
|
|
2183
2413
|
onSudoPasswordInput: handleSudoPasswordInput,
|
|
2184
2414
|
onSubmit: handleSubmit,
|
|
2185
2415
|
};
|
|
@@ -2193,7 +2423,10 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2193
2423
|
}
|
|
2194
2424
|
}, 120);
|
|
2195
2425
|
const clockTimer = setInterval(() => {
|
|
2196
|
-
if (
|
|
2426
|
+
if (hasRunningBackgroundJobs()) {
|
|
2427
|
+
syncBackgroundJobsState();
|
|
2428
|
+
}
|
|
2429
|
+
if (store.getState().busy || hasRunningBackgroundJobs()) {
|
|
2197
2430
|
renderCurrentFrame();
|
|
2198
2431
|
}
|
|
2199
2432
|
}, 1000);
|
|
@@ -2237,6 +2470,9 @@ export async function runClientInteractive({ appendPromptHistory, authConfig, de
|
|
|
2237
2470
|
clearInterval(clockTimer);
|
|
2238
2471
|
unsubscribe();
|
|
2239
2472
|
clearLiveFrameRemountTimer();
|
|
2473
|
+
killAllBackgroundJobs();
|
|
2474
|
+
setBackgroundJobSession(null);
|
|
2475
|
+
setBackgroundJobUpdateHook(null);
|
|
2240
2476
|
await bridge.close();
|
|
2241
2477
|
setCommandOutputHook(null);
|
|
2242
2478
|
}
|
|
@@ -160,6 +160,7 @@ const CLIENT_SLASH_COMMANDS = [
|
|
|
160
160
|
{ command: '/usage', description: 'Show account usage percentage and reset times' },
|
|
161
161
|
{ command: '/model', description: 'Switch the active model' },
|
|
162
162
|
{ command: '/resume', description: 'Open the session picker to resume a previous session' },
|
|
163
|
+
{ command: '/jobs', description: 'Background jobs: pick to view output or kill' },
|
|
163
164
|
{ command: '/clear', description: 'Clear conversation history' },
|
|
164
165
|
{ command: '/exit', description: 'Quit the current session' },
|
|
165
166
|
];
|
|
@@ -286,6 +287,17 @@ function footerTransientStatus(status) {
|
|
|
286
287
|
}
|
|
287
288
|
return null;
|
|
288
289
|
}
|
|
290
|
+
function backgroundJobIndicatorSpans(state) {
|
|
291
|
+
const runningCount = (state.backgroundJobs ?? []).filter((job) => job.status === 'running').length;
|
|
292
|
+
if (runningCount === 0)
|
|
293
|
+
return [];
|
|
294
|
+
const noun = runningCount === 1 ? 'shell' : 'shells';
|
|
295
|
+
return [
|
|
296
|
+
span(' ', { color: 'gray', dim: true }),
|
|
297
|
+
span(`● ${runningCount} ${noun} running`, { color: 'green', bold: true }),
|
|
298
|
+
span(' · /jobs', { color: 'gray', dim: true }),
|
|
299
|
+
];
|
|
300
|
+
}
|
|
289
301
|
function composerFooterLines(state) {
|
|
290
302
|
const visibleSessionId = formatPromptSessionIdLabel(state.showSessionId, state.sessionId);
|
|
291
303
|
const transientStatus = footerTransientStatus(state.status);
|
|
@@ -304,7 +316,7 @@ function composerFooterLines(state) {
|
|
|
304
316
|
]
|
|
305
317
|
: []), ...(visibleSessionId
|
|
306
318
|
? [span(` ${visibleSessionId}`, { color: 'gray', dim: true })]
|
|
307
|
-
: [])),
|
|
319
|
+
: []), ...backgroundJobIndicatorSpans(state)),
|
|
308
320
|
plainLine(''),
|
|
309
321
|
plainLine(formatModelLabel(state.currentModelId, state.serverModels), {
|
|
310
322
|
color: 'cyan',
|
|
@@ -341,6 +353,85 @@ function composerFooterLines(state) {
|
|
|
341
353
|
lines.push(line(...footerSpans));
|
|
342
354
|
return lines;
|
|
343
355
|
}
|
|
356
|
+
export function formatJobElapsed(elapsedMs) {
|
|
357
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
358
|
+
if (totalSeconds < 60)
|
|
359
|
+
return `${totalSeconds}s`;
|
|
360
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
361
|
+
if (minutes < 60) {
|
|
362
|
+
return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
|
|
363
|
+
}
|
|
364
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
365
|
+
}
|
|
366
|
+
function backgroundJobPreviewLines(job, maxTailLines) {
|
|
367
|
+
const lines = [];
|
|
368
|
+
const first = String(job.firstOutputLine ?? '').trim();
|
|
369
|
+
if (first) {
|
|
370
|
+
lines.push(first);
|
|
371
|
+
}
|
|
372
|
+
for (const outputLine of (job.tailLines ?? []).slice(-maxTailLines)) {
|
|
373
|
+
const trimmed = String(outputLine ?? '').trim();
|
|
374
|
+
if (!trimmed)
|
|
375
|
+
continue;
|
|
376
|
+
if (lines.length === 1 && trimmed === first)
|
|
377
|
+
continue;
|
|
378
|
+
lines.push(trimmed);
|
|
379
|
+
}
|
|
380
|
+
return lines;
|
|
381
|
+
}
|
|
382
|
+
function jobStatusDescriptor(job, nowMs) {
|
|
383
|
+
const ran = formatJobElapsed((job.endedAt ?? (nowMs > 0 ? nowMs : Date.now())) - job.startedAt);
|
|
384
|
+
if (job.status === 'running') {
|
|
385
|
+
return { glyph: '●', color: 'green', text: `running · ${ran}` };
|
|
386
|
+
}
|
|
387
|
+
if (job.status === 'killed') {
|
|
388
|
+
return { glyph: '■', color: 'gray', text: `killed · ran ${ran}` };
|
|
389
|
+
}
|
|
390
|
+
if (job.status === 'error') {
|
|
391
|
+
return { glyph: '✖', color: 'red', text: 'failed to start' };
|
|
392
|
+
}
|
|
393
|
+
return job.exitCode === 0
|
|
394
|
+
? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
|
|
395
|
+
: { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
|
|
396
|
+
}
|
|
397
|
+
function buildJobsPickerLines(state, width, nowMs) {
|
|
398
|
+
const jobs = state.backgroundJobs ?? [];
|
|
399
|
+
const lines = [
|
|
400
|
+
plainLine('Background jobs', { color: 'cyan', bold: true }),
|
|
401
|
+
];
|
|
402
|
+
if (jobs.length === 0) {
|
|
403
|
+
lines.push(plainLine('No background jobs in this session.', { color: 'gray' }));
|
|
404
|
+
}
|
|
405
|
+
for (const [index, job] of jobs.entries()) {
|
|
406
|
+
const selected = index === state.jobsPickerIndex;
|
|
407
|
+
const expanded = state.jobsPickerExpandedId === job.id;
|
|
408
|
+
const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
|
|
409
|
+
lines.push(line(span(selected ? '› ' : ' ', { color: selected ? 'cyan' : 'gray' }), span(`${glyph} `, { color }), span(job.id.padEnd(6), { color: selected ? 'cyan' : undefined, bold: selected }), span(` ${truncate(job.command, Math.max(12, width - 34)).padEnd(Math.max(12, Math.min(40, width - 34)))}`, {
|
|
410
|
+
color: selected ? 'cyan' : undefined,
|
|
411
|
+
bold: selected,
|
|
412
|
+
}), span(` ${text}`, { color: 'gray' })));
|
|
413
|
+
if (expanded) {
|
|
414
|
+
const detailLines = (job.detailLines ?? []).length
|
|
415
|
+
? job.detailLines ?? []
|
|
416
|
+
: backgroundJobPreviewLines(job, 3);
|
|
417
|
+
if (detailLines.length === 0) {
|
|
418
|
+
lines.push(plainLine(' (no output captured)', { color: 'gray' }));
|
|
419
|
+
}
|
|
420
|
+
else {
|
|
421
|
+
for (const outputLine of detailLines) {
|
|
422
|
+
lines.push(line(span(' │ ', { color: 'gray', dim: true }), span(truncate(outputLine, Math.max(8, width - 8)), {
|
|
423
|
+
color: 'gray',
|
|
424
|
+
dim: true,
|
|
425
|
+
})));
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
lines.push(plainLine('↑/↓ move enter expand/collapse k kill esc close', {
|
|
431
|
+
color: 'gray',
|
|
432
|
+
}));
|
|
433
|
+
return lines;
|
|
434
|
+
}
|
|
344
435
|
function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
|
|
345
436
|
if (!state.busy)
|
|
346
437
|
return [];
|
|
@@ -551,7 +642,7 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
|
|
|
551
642
|
body.push(modelPickerPanelSideLine(plainLine(''), innerWidth), modelPickerPanelSideLine(line(span('─'.repeat(innerWidth), { color: MODEL_PICKER_BORDER_COLOR })), innerWidth), modelPickerPanelSideLine(plainLine('↑/↓ choose • Tab or Enter accept • Esc cancel', { color: 'gray' }), innerWidth), plainLine(`╰${'─'.repeat(panelWidth - 2)}╯`, { color: MODEL_PICKER_BORDER_COLOR }));
|
|
552
643
|
return [...margin, ...body, ...margin];
|
|
553
644
|
}
|
|
554
|
-
function buildOverlayLines(state, width) {
|
|
645
|
+
function buildOverlayLines(state, width, nowMs) {
|
|
555
646
|
const lines = [];
|
|
556
647
|
const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
|
|
557
648
|
const innerWidth = Math.max(1, panelWidth - 4);
|
|
@@ -626,6 +717,9 @@ function buildOverlayLines(state, width) {
|
|
|
626
717
|
const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
|
|
627
718
|
lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width));
|
|
628
719
|
}
|
|
720
|
+
if (state.jobsPickerOpen) {
|
|
721
|
+
lines.push(...buildJobsPickerLines(state, width, nowMs));
|
|
722
|
+
}
|
|
629
723
|
if (state.resumePickerOpen) {
|
|
630
724
|
const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
|
|
631
725
|
lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
|
|
@@ -667,7 +761,7 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
|
|
|
667
761
|
const start = Math.max(0, lines.length - maxLines - offset);
|
|
668
762
|
return lines.slice(start, start + maxLines);
|
|
669
763
|
}
|
|
670
|
-
export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
|
|
764
|
+
export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
|
|
671
765
|
const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
|
|
672
766
|
const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
|
|
673
767
|
const transcriptBlocks = state.transcript.map((entry) => renderTranscriptEntryLines(entry, contentWidth));
|
|
@@ -684,7 +778,7 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
|
|
|
684
778
|
sections.push({ kind: 'live', lines: liveLines });
|
|
685
779
|
}
|
|
686
780
|
const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
|
|
687
|
-
if (!state.resumePickerOpen && !state.modelPickerOpen && !overlayActive) {
|
|
781
|
+
if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
|
|
688
782
|
const composerLines = [];
|
|
689
783
|
if (state.queuedMessage) {
|
|
690
784
|
const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
|
|
@@ -705,7 +799,7 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
|
|
|
705
799
|
lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
|
|
706
800
|
});
|
|
707
801
|
}
|
|
708
|
-
const overlayLines = buildOverlayLines(state, contentWidth);
|
|
802
|
+
const overlayLines = buildOverlayLines(state, contentWidth, nowMs);
|
|
709
803
|
if (overlayLines.length > 0) {
|
|
710
804
|
sections.push({ kind: 'overlay', lines: overlayLines });
|
|
711
805
|
}
|
|
@@ -12,6 +12,7 @@ function isClipboardImagePasteKey(key) {
|
|
|
12
12
|
function shouldShowCommandPalette(state) {
|
|
13
13
|
if (state.busy ||
|
|
14
14
|
state.exiting ||
|
|
15
|
+
state.jobsPickerOpen ||
|
|
15
16
|
state.modelPickerOpen ||
|
|
16
17
|
state.resumePickerOpen) {
|
|
17
18
|
return false;
|
|
@@ -57,6 +58,7 @@ function pasteTextFromClipboard(store, handlers) {
|
|
|
57
58
|
const current = store.getState();
|
|
58
59
|
if (current.exiting ||
|
|
59
60
|
current.approvalPrompt ||
|
|
61
|
+
current.jobsPickerOpen ||
|
|
60
62
|
current.modelPickerOpen ||
|
|
61
63
|
current.resumePickerOpen ||
|
|
62
64
|
current.sudoPrompt) {
|
|
@@ -210,6 +212,37 @@ export function handleShellKeyEvent(store, handlers, event) {
|
|
|
210
212
|
});
|
|
211
213
|
return;
|
|
212
214
|
}
|
|
215
|
+
if (state.jobsPickerOpen) {
|
|
216
|
+
if (key.escape) {
|
|
217
|
+
store.update((current) => ({
|
|
218
|
+
...current,
|
|
219
|
+
jobsPickerExpandedId: null,
|
|
220
|
+
jobsPickerOpen: false,
|
|
221
|
+
status: 'Ready',
|
|
222
|
+
}));
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
if (key.upArrow || key.downArrow) {
|
|
226
|
+
store.update((current) => {
|
|
227
|
+
const count = (current.backgroundJobs ?? []).length;
|
|
228
|
+
const next = current.jobsPickerIndex + (key.upArrow ? -1 : 1);
|
|
229
|
+
return {
|
|
230
|
+
...current,
|
|
231
|
+
jobsPickerExpandedId: null,
|
|
232
|
+
jobsPickerIndex: Math.max(0, Math.min(next, Math.max(count - 1, 0))),
|
|
233
|
+
};
|
|
234
|
+
});
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (key.returnKey) {
|
|
238
|
+
void handlers.onJobsPickerOutput();
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
if (key.input === 'k' && !key.ctrl && !key.meta && !key.shift) {
|
|
242
|
+
void handlers.onJobsPickerKill();
|
|
243
|
+
}
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
213
246
|
if (state.modelPickerOpen) {
|
|
214
247
|
if (key.escape) {
|
|
215
248
|
store.update((current) => ({ ...current, modelPickerOpen: false }));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@thegitai/cli",
|
|
3
|
-
"version": "1.0.0-beta.
|
|
3
|
+
"version": "1.0.0-beta.18",
|
|
4
4
|
"description": "TheGitAI CLI client (source-visible, proprietary)",
|
|
5
5
|
"license": "SEE LICENSE IN LICENSE",
|
|
6
6
|
"homepage": "https://thegit.ai",
|
|
@@ -25,10 +25,10 @@
|
|
|
25
25
|
"@lydell/node-pty-linux-x64": "1.1.0",
|
|
26
26
|
"@lydell/node-pty-win32-arm64": "1.1.0",
|
|
27
27
|
"@lydell/node-pty-win32-x64": "1.1.0",
|
|
28
|
-
"@thegitai/tui-darwin-arm64": "1.0.0-beta.
|
|
29
|
-
"@thegitai/tui-darwin-x64": "1.0.0-beta.
|
|
30
|
-
"@thegitai/tui-linux-x64": "1.0.0-beta.
|
|
31
|
-
"@thegitai/tui-win32-x64": "1.0.0-beta.
|
|
28
|
+
"@thegitai/tui-darwin-arm64": "1.0.0-beta.18",
|
|
29
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.18",
|
|
30
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.18",
|
|
31
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.18",
|
|
32
32
|
"@vscode/ripgrep": "1.18.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|