@thegitai/cli 1.0.0-beta.15 → 1.0.0-beta.17
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 +6 -0
- package/dist/src/api/chat.js +46 -9
- package/dist/src/background-jobs.js +410 -0
- package/dist/src/client-environment.js +2 -0
- package/dist/src/core/image-path-extractor.js +55 -4
- package/dist/src/executor.js +27 -7
- package/dist/src/help-text.js +10 -0
- package/dist/src/scratch-dir.js +57 -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 -13
- package/dist/src/tools/run-node-script.js +2 -0
- 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 +92 -5
- package/dist/src/ui/tui/shell-input.js +31 -0
- package/package.json +5 -5
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { killBackgroundJob } from '../background-jobs.js';
|
|
2
|
+
import { redactConnectionStringCredentials } from '../secret-preview.js';
|
|
3
|
+
const MAX_JOB_TOOL_OUTPUT_CHARS = 64 * 1024;
|
|
4
|
+
function boundJobToolOutput(output) {
|
|
5
|
+
if (output.length <= MAX_JOB_TOOL_OUTPUT_CHARS)
|
|
6
|
+
return output;
|
|
7
|
+
const headSize = Math.floor(MAX_JOB_TOOL_OUTPUT_CHARS * 0.2);
|
|
8
|
+
const tailSize = MAX_JOB_TOOL_OUTPUT_CHARS - headSize;
|
|
9
|
+
return (output.slice(0, headSize) +
|
|
10
|
+
`\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
|
|
11
|
+
output.slice(-tailSize));
|
|
12
|
+
}
|
|
13
|
+
export async function shellJobKill(context, args) {
|
|
14
|
+
const jobId = String(args.job_id ?? '').trim();
|
|
15
|
+
if (!jobId) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
error: 'job_id is required',
|
|
19
|
+
failureCategory: 'missing_required_argument',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const result = await killBackgroundJob(jobId, {
|
|
23
|
+
sessionId: context.sessionId,
|
|
24
|
+
});
|
|
25
|
+
if (!result.ok || !result.snapshot) {
|
|
26
|
+
return {
|
|
27
|
+
ok: false,
|
|
28
|
+
error: result.error ?? 'Background job lookup failed.',
|
|
29
|
+
failureCategory: 'not_found',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const snapshot = result.snapshot;
|
|
33
|
+
let finalOutput = redactConnectionStringCredentials(result.finalOutput ?? '');
|
|
34
|
+
if (result.droppedChars) {
|
|
35
|
+
finalOutput = `... (${result.droppedChars} chars of older output dropped) ...\n${finalOutput}`;
|
|
36
|
+
}
|
|
37
|
+
finalOutput = boundJobToolOutput(finalOutput);
|
|
38
|
+
return {
|
|
39
|
+
ok: true,
|
|
40
|
+
jobId: snapshot.id,
|
|
41
|
+
command: snapshot.command,
|
|
42
|
+
status: snapshot.status,
|
|
43
|
+
exitCode: snapshot.exitCode,
|
|
44
|
+
alreadyFinished: result.alreadyFinished === true,
|
|
45
|
+
ranMs: (snapshot.endedAt ?? Date.now()) - snapshot.startedAt,
|
|
46
|
+
finalOutput,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { MAX_JOB_WAIT_MS, readBackgroundJobOutput, } from '../background-jobs.js';
|
|
2
|
+
import { redactConnectionStringCredentials } from '../secret-preview.js';
|
|
3
|
+
const MAX_JOB_TOOL_OUTPUT_CHARS = 64 * 1024;
|
|
4
|
+
function boundJobToolOutput(output) {
|
|
5
|
+
if (output.length <= MAX_JOB_TOOL_OUTPUT_CHARS)
|
|
6
|
+
return output;
|
|
7
|
+
const headSize = Math.floor(MAX_JOB_TOOL_OUTPUT_CHARS * 0.2);
|
|
8
|
+
const tailSize = MAX_JOB_TOOL_OUTPUT_CHARS - headSize;
|
|
9
|
+
return (output.slice(0, headSize) +
|
|
10
|
+
`\n\n... (${output.length - headSize - tailSize} chars truncated) ...\n\n` +
|
|
11
|
+
output.slice(-tailSize));
|
|
12
|
+
}
|
|
13
|
+
export async function shellJobOutput(context, args) {
|
|
14
|
+
const jobId = String(args.job_id ?? '').trim();
|
|
15
|
+
if (!jobId) {
|
|
16
|
+
return {
|
|
17
|
+
ok: false,
|
|
18
|
+
error: 'job_id is required',
|
|
19
|
+
failureCategory: 'missing_required_argument',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
const waitMs = typeof args.wait_ms === 'number' && args.wait_ms > 0
|
|
23
|
+
? Math.min(args.wait_ms, MAX_JOB_WAIT_MS)
|
|
24
|
+
: 0;
|
|
25
|
+
const result = await readBackgroundJobOutput(jobId, {
|
|
26
|
+
waitMs,
|
|
27
|
+
sessionId: context.sessionId,
|
|
28
|
+
});
|
|
29
|
+
if (!result.ok || !result.snapshot) {
|
|
30
|
+
return {
|
|
31
|
+
ok: false,
|
|
32
|
+
error: result.error ?? 'Background job lookup failed.',
|
|
33
|
+
failureCategory: 'not_found',
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
const snapshot = result.snapshot;
|
|
37
|
+
let newOutput = redactConnectionStringCredentials(result.newOutput ?? '');
|
|
38
|
+
if (result.droppedChars) {
|
|
39
|
+
newOutput = `... (${result.droppedChars} chars of older output dropped) ...\n${newOutput}`;
|
|
40
|
+
}
|
|
41
|
+
newOutput = boundJobToolOutput(newOutput);
|
|
42
|
+
return {
|
|
43
|
+
ok: true,
|
|
44
|
+
jobId: snapshot.id,
|
|
45
|
+
command: snapshot.command,
|
|
46
|
+
status: snapshot.status,
|
|
47
|
+
exitCode: snapshot.exitCode,
|
|
48
|
+
elapsedMs: (snapshot.endedAt ?? Date.now()) - snapshot.startedAt,
|
|
49
|
+
newOutput,
|
|
50
|
+
};
|
|
51
|
+
}
|
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
|
];
|
|
@@ -341,6 +342,83 @@ function composerFooterLines(state) {
|
|
|
341
342
|
lines.push(line(...footerSpans));
|
|
342
343
|
return lines;
|
|
343
344
|
}
|
|
345
|
+
export function formatJobElapsed(elapsedMs) {
|
|
346
|
+
const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1000));
|
|
347
|
+
if (totalSeconds < 60)
|
|
348
|
+
return `${totalSeconds}s`;
|
|
349
|
+
const minutes = Math.floor(totalSeconds / 60);
|
|
350
|
+
if (minutes < 60) {
|
|
351
|
+
return `${minutes}m${String(totalSeconds % 60).padStart(2, '0')}s`;
|
|
352
|
+
}
|
|
353
|
+
return `${Math.floor(minutes / 60)}h${String(minutes % 60).padStart(2, '0')}m`;
|
|
354
|
+
}
|
|
355
|
+
function buildBackgroundJobsLine(state, width, nowMs) {
|
|
356
|
+
const running = (state.backgroundJobs ?? []).filter((job) => job.status === 'running');
|
|
357
|
+
if (running.length === 0)
|
|
358
|
+
return null;
|
|
359
|
+
const countLabel = `${running.length} job${running.length === 1 ? '' : 's'}`;
|
|
360
|
+
const parts = running.map((job) => {
|
|
361
|
+
const elapsed = nowMs > 0 ? ` · ${formatJobElapsed(nowMs - job.startedAt)}` : '';
|
|
362
|
+
return `${truncate(job.command, 48)}${elapsed}`;
|
|
363
|
+
});
|
|
364
|
+
const text = truncate(`${countLabel} · ${parts.join(' · ')}`, Math.max(8, width - 2));
|
|
365
|
+
return line(span('● ', { color: 'green' }), span(text, { color: 'gray', dim: true }));
|
|
366
|
+
}
|
|
367
|
+
const JOB_LIVE_TAIL_LINES = 8;
|
|
368
|
+
const JOB_FINISHED_TAIL_LINES = 3;
|
|
369
|
+
function jobStatusDescriptor(job, nowMs) {
|
|
370
|
+
const ran = formatJobElapsed((job.endedAt ?? (nowMs > 0 ? nowMs : Date.now())) - job.startedAt);
|
|
371
|
+
if (job.status === 'running') {
|
|
372
|
+
return { glyph: '●', color: 'green', text: `running · ${ran}` };
|
|
373
|
+
}
|
|
374
|
+
if (job.status === 'killed') {
|
|
375
|
+
return { glyph: '■', color: 'gray', text: `killed · ran ${ran}` };
|
|
376
|
+
}
|
|
377
|
+
if (job.status === 'error') {
|
|
378
|
+
return { glyph: '✖', color: 'red', text: 'failed to start' };
|
|
379
|
+
}
|
|
380
|
+
return job.exitCode === 0
|
|
381
|
+
? { glyph: '✓', color: 'green', text: `exited (0) · ran ${ran}` }
|
|
382
|
+
: { glyph: '✖', color: 'red', text: `exited (${job.exitCode ?? 1}) · ran ${ran}` };
|
|
383
|
+
}
|
|
384
|
+
function renderBackgroundJobEntryLines(entry, state, width, nowMs) {
|
|
385
|
+
const job = (state.backgroundJobs ?? []).find((candidate) => candidate.id === entry.jobId);
|
|
386
|
+
if (!job) {
|
|
387
|
+
return renderTranscriptEntryLines({ ...entry, jobId: undefined }, width);
|
|
388
|
+
}
|
|
389
|
+
const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
|
|
390
|
+
const header = line(span(`${glyph} `, { color }), span(`${job.id} · ${truncate(job.command, Math.max(12, width - 24))}`, {
|
|
391
|
+
color,
|
|
392
|
+
bold: true,
|
|
393
|
+
}), span(` · ${text}`, { color: 'gray' }));
|
|
394
|
+
const tailBudget = job.status === 'running' ? JOB_LIVE_TAIL_LINES : JOB_FINISHED_TAIL_LINES;
|
|
395
|
+
const tail = (job.tailLines ?? []).slice(-tailBudget).map((tailLine) => line(span('│ ', { color: 'gray', dim: true }), span(truncate(tailLine, Math.max(8, width - 4)), {
|
|
396
|
+
color: 'gray',
|
|
397
|
+
dim: true,
|
|
398
|
+
})));
|
|
399
|
+
return [header, ...tail];
|
|
400
|
+
}
|
|
401
|
+
function buildJobsPickerLines(state, width, nowMs) {
|
|
402
|
+
const jobs = state.backgroundJobs ?? [];
|
|
403
|
+
const lines = [
|
|
404
|
+
plainLine('Background jobs', { color: 'cyan', bold: true }),
|
|
405
|
+
];
|
|
406
|
+
if (jobs.length === 0) {
|
|
407
|
+
lines.push(plainLine('No background jobs in this session.', { color: 'gray' }));
|
|
408
|
+
}
|
|
409
|
+
for (const [index, job] of jobs.entries()) {
|
|
410
|
+
const selected = index === state.jobsPickerIndex;
|
|
411
|
+
const { glyph, color, text } = jobStatusDescriptor(job, nowMs);
|
|
412
|
+
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)))}`, {
|
|
413
|
+
color: selected ? 'cyan' : undefined,
|
|
414
|
+
bold: selected,
|
|
415
|
+
}), span(` ${text}`, { color: 'gray' })));
|
|
416
|
+
}
|
|
417
|
+
lines.push(plainLine('↑/↓ move enter output k kill esc close', {
|
|
418
|
+
color: 'gray',
|
|
419
|
+
}));
|
|
420
|
+
return lines;
|
|
421
|
+
}
|
|
344
422
|
function buildLiveLines(state, width, spinnerFrame, elapsedSeconds) {
|
|
345
423
|
if (!state.busy)
|
|
346
424
|
return [];
|
|
@@ -551,7 +629,7 @@ function buildCommandPalettePanel(suggestions, selectedIndex, width) {
|
|
|
551
629
|
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
630
|
return [...margin, ...body, ...margin];
|
|
553
631
|
}
|
|
554
|
-
function buildOverlayLines(state, width) {
|
|
632
|
+
function buildOverlayLines(state, width, nowMs) {
|
|
555
633
|
const lines = [];
|
|
556
634
|
const panelWidth = Math.max(24, Math.min(width, OVERLAY_PANEL_MAX_WIDTH));
|
|
557
635
|
const innerWidth = Math.max(1, panelWidth - 4);
|
|
@@ -626,6 +704,9 @@ function buildOverlayLines(state, width) {
|
|
|
626
704
|
const options = buildModelPickerOptions(state.currentModelId, state.serverModels);
|
|
627
705
|
lines.push(...buildModelPickerPanel(options, state.modelPickerIndex, width));
|
|
628
706
|
}
|
|
707
|
+
if (state.jobsPickerOpen) {
|
|
708
|
+
lines.push(...buildJobsPickerLines(state, width, nowMs));
|
|
709
|
+
}
|
|
629
710
|
if (state.resumePickerOpen) {
|
|
630
711
|
const filtered = filterResumeSessions(state.resumePickerSessions, state.resumePickerFilter, state.serverModels);
|
|
631
712
|
lines.push(plainLine('Resume a previous session', { color: 'cyan', bold: true }));
|
|
@@ -667,10 +748,12 @@ function sliceTranscriptLines(lines, maxLines, scrollOffset) {
|
|
|
667
748
|
const start = Math.max(0, lines.length - maxLines - offset);
|
|
668
749
|
return lines.slice(start, start + maxLines);
|
|
669
750
|
}
|
|
670
|
-
export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
|
|
751
|
+
export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds, nowMs = 0) {
|
|
671
752
|
const contentWidth = Math.max(20, Math.floor(cols * 0.95) - 2);
|
|
672
753
|
const gutter = Math.max(Math.floor((cols - contentWidth) / 2), 0);
|
|
673
|
-
const transcriptBlocks = state.transcript.map((entry) =>
|
|
754
|
+
const transcriptBlocks = state.transcript.map((entry) => entry.jobId
|
|
755
|
+
? renderBackgroundJobEntryLines(entry, state, contentWidth, nowMs)
|
|
756
|
+
: renderTranscriptEntryLines(entry, contentWidth));
|
|
674
757
|
const transcriptLines = [];
|
|
675
758
|
transcriptBlocks.forEach((block, index) => {
|
|
676
759
|
if (index > 0) {
|
|
@@ -684,7 +767,11 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
|
|
|
684
767
|
sections.push({ kind: 'live', lines: liveLines });
|
|
685
768
|
}
|
|
686
769
|
const overlayActive = Boolean(state.approvalPrompt || state.sudoPrompt);
|
|
687
|
-
if (!state.resumePickerOpen && !state.modelPickerOpen && !overlayActive) {
|
|
770
|
+
if (!state.resumePickerOpen && !state.modelPickerOpen && !state.jobsPickerOpen && !overlayActive) {
|
|
771
|
+
const jobsLine = buildBackgroundJobsLine(state, contentWidth, nowMs);
|
|
772
|
+
if (jobsLine) {
|
|
773
|
+
sections.push({ kind: 'busyFooter', lines: [jobsLine, plainLine('')] });
|
|
774
|
+
}
|
|
688
775
|
const composerLines = [];
|
|
689
776
|
if (state.queuedMessage) {
|
|
690
777
|
const preview = truncate(state.queuedMessage.body.trim().replace(/\s+/g, ' '), 60);
|
|
@@ -705,7 +792,7 @@ export function buildTuiFrame(state, cols, rows, spinnerFrame, elapsedSeconds) {
|
|
|
705
792
|
lines: [...composerLines, plainLine(''), ...composerFooterLines(state)],
|
|
706
793
|
});
|
|
707
794
|
}
|
|
708
|
-
const overlayLines = buildOverlayLines(state, contentWidth);
|
|
795
|
+
const overlayLines = buildOverlayLines(state, contentWidth, nowMs);
|
|
709
796
|
if (overlayLines.length > 0) {
|
|
710
797
|
sections.push({ kind: 'overlay', lines: overlayLines });
|
|
711
798
|
}
|
|
@@ -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,35 @@ 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
|
+
jobsPickerOpen: false,
|
|
220
|
+
status: 'Ready',
|
|
221
|
+
}));
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
if (key.upArrow || key.downArrow) {
|
|
225
|
+
store.update((current) => {
|
|
226
|
+
const count = (current.backgroundJobs ?? []).length;
|
|
227
|
+
const next = current.jobsPickerIndex + (key.upArrow ? -1 : 1);
|
|
228
|
+
return {
|
|
229
|
+
...current,
|
|
230
|
+
jobsPickerIndex: Math.max(0, Math.min(next, Math.max(count - 1, 0))),
|
|
231
|
+
};
|
|
232
|
+
});
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (key.returnKey) {
|
|
236
|
+
void handlers.onJobsPickerOutput();
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
if (key.input === 'k' && !key.ctrl && !key.meta && !key.shift) {
|
|
240
|
+
void handlers.onJobsPickerKill();
|
|
241
|
+
}
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
213
244
|
if (state.modelPickerOpen) {
|
|
214
245
|
if (key.escape) {
|
|
215
246
|
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.17",
|
|
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.17",
|
|
29
|
+
"@thegitai/tui-darwin-x64": "1.0.0-beta.17",
|
|
30
|
+
"@thegitai/tui-linux-x64": "1.0.0-beta.17",
|
|
31
|
+
"@thegitai/tui-win32-x64": "1.0.0-beta.17",
|
|
32
32
|
"@vscode/ripgrep": "1.18.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|